improvement!: Major redesign of DSL and code structure. (#35)

Highlights:

* Replaced `AshAuthentication.Provider` with the much more flexible `AshAuthentication.Strategy`.
* Moved strategies to within the `authentication` DSL using entities and removed excess extensions.
* Added a lot more documentation and test coverage.
This commit is contained in:
James Harton 2022-11-23 09:09:41 +13:00 committed by GitHub
parent fdba938b70
commit 1c8f138c67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
144 changed files with 5217 additions and 4411 deletions

18
LICENSE Normal file
View file

@ -0,0 +1,18 @@
Copyright 2022 Alembic Pty Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -19,17 +19,16 @@ end
## Usage
This package assumes that you have [Phoenix](https://phoenixframework.org/) and
[Ash](https://ash-hq.org/) installed and configured. See their individual
documentation for details.
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 one
or more extensions onto your Ash resource:
Once installed you can easily add support for authentication by adding the
`AshAuthentication` extension to your resource:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
extensions: [AshAuthentication]
attributes do
uuid_primary_key :id
@ -39,11 +38,13 @@ defmodule MyApp.Accounts.User do
authentication do
api MyApp.Accounts
end
password_authentication do
identity_field :email
hashed_password_field :hashed_password
strategies do
password do
identity_field :email
hashed_password_field :hashed_password
end
end
end
identities do
@ -55,7 +56,7 @@ end
If you plan on providing authentication via the web, then you will need to
define a plug using
[`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html)
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that
routes incoming authentication requests to the correct provider and provides
callbacks for you to manipulate the conn after success or failure.
@ -64,18 +65,16 @@ If you're using AshAuthentication with Phoenix, then check out
which provides route helpers, a controller abstraction and LiveView components
for easy set up.
## Authentication Providers
## Authentication Strategies
Currently the only supported authentication provider is
[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html)
which provides actions for registering and signing in users using an identifier
and a password.
Currently supported strategies:
Planned future providers include:
* OAuth 1.0
* OAuth 2.0
* OpenID Connect
1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html)
- authenticate users against your local database using a unique identity
(such as username or email address) and a password.
2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html)
- authenticate using local or remote [OAuth 2.0](https://oauth.net/2/)
compatible services.
## Documentation
@ -83,6 +82,10 @@ Documentation for the latest release will be [available on
hexdocs](https://hexdocs.pm/ash_authentication) and for the [`main`
branch](https://team-alembic.github.io/ash_authentication).
Additional support can be found on the [GitHub discussions
page](https://github.com/team-alembic/ash_authentication/discussions) and the
[Ash Discord](https://discord.gg/D7FNG2q).
## Contributing
* To contribute updates, fixes or new features please fork and open a
@ -95,4 +98,7 @@ branch](https://team-alembic.github.io/ash_authentication).
## Licence
MIT
`AshAuthentication` is licensed under the terms of the [MIT
license](https://opensource.org/licenses/MIT). See the [`LICENSE` file in this
repository](https://github.com/team-alembic/ash_authentication/blob/main/LICENSE)
for details.

View file

@ -28,3 +28,18 @@ config :ash_authentication, Example,
config :ash_authentication, AshAuthentication.Jwt,
signing_secret: "Marty McFly in the past with the Delorean"
config :ash_authentication,
authentication: [
strategies: [
oauth2: [
client_id: System.get_env("OAUTH2_CLIENT_ID"),
redirect_uri: "http://localhost:4000/auth",
client_secret: System.get_env("OAUTH2_CLIENT_SECRET"),
site: System.get_env("OAUTH2_SITE"),
authorize_path: "/authorize",
token_path: "/oauth/token",
user_path: "/userinfo"
]
]
]

View file

@ -21,3 +21,18 @@ config :ash, :disable_async?, true
config :ash_authentication, AshAuthentication.Jwt,
signing_secret: "Marty McFly in the past with the Delorean"
config :ash_authentication,
authentication: [
strategies: [
oauth2: [
client_id: "pretend client id",
redirect_uri: "http://localhost:4000/auth",
client_secret: "pretend client secret",
site: "https://example.com/",
authorize_path: "/authorize",
token_path: "/oauth/token",
user_path: "/userinfo"
]
]
]

View file

@ -6,7 +6,7 @@ defmodule DevServer.ApiRouter do
import Example.AuthPlug
plug(:load_from_bearer)
plug(:set_actor, :user_with_username)
plug(:set_actor, :user)
plug(:match)
plug(:dispatch)

View file

@ -6,7 +6,7 @@ defmodule DevServer.GqlRouter do
import Example.AuthPlug
plug(:load_from_bearer)
plug(:set_actor, :user_with_username)
plug(:set_actor, :user)
plug(AshGraphql.Plug)
plug(:match)
plug(:dispatch)

View file

@ -4,6 +4,7 @@ defmodule DevServer.TestPage do
Überauth providers.
"""
@behaviour Plug
alias AshAuthentication.{Info, Strategy}
alias Plug.Conn
require EEx
@ -20,7 +21,10 @@ defmodule DevServer.TestPage do
@spec call(Conn.t(), any) :: Conn.t()
@impl true
def call(conn, _opts) do
resources = AshAuthentication.authenticated_resources(:ash_authentication)
resources =
:ash_authentication
|> AshAuthentication.authenticated_resources()
|> Enum.map(&{&1, Info.authentication_options(&1), Info.authentication_strategies(&1)})
current_users =
conn.assigns
@ -34,4 +38,157 @@ defmodule DevServer.TestPage do
payload = render(resources: resources, current_users: current_users)
Conn.send_resp(conn, 200, payload)
end
defp render_strategy(strategy, phase, options)
when strategy.provider == :password and phase == :register do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend>Register with <%= @strategy.name %></legend>
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
<br />
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
<br />
<%= if @strategy.confirmation_required? do %>
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_confirmation_field %>]" placeholder="<%= @strategy.password_confirmation_field %>" />
<br />
<% end %>
<input type="submit" value="Register" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, options)
when strategy.provider == :password and phase == :sign_in do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend>Sign in with <%= @strategy.name %></legend>
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
<br />
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
<br />
<input type="submit" value="Sign in" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, options)
when strategy.provider == :password and phase == :reset_request do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend><%= @strategy.name %> reset request</legend>
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
<br />
<input type="submit" value="Request reset" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, options)
when strategy.provider == :password and phase == :reset do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend><%= @strategy.name %> reset request</legend>
<input type="text" name="reset_token" placeholder="reset_token" />
<br />
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_field %>]" placeholder="<%= @strategy.password_field %>" />
<br />
<%= if @strategy.confirmation_required? do %>
<input type="password" name="<%= @options.subject_name %>[<%= @strategy.password_confirmation_field %>]" placeholder="<%= @strategy.password_confirmation_field %>" />
<br />
<% end %>
<input type="submit" value="Reset" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, options)
when strategy.provider == :confirmation and phase == :confirm do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend><%= @strategy.name %></legend>
<input type="text" name="confirm" placeholder="confirmation token" />
<br />
<input type="submit" value="Confirm" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, _options)
when strategy.provider == :oauth2 and phase == :request do
EEx.eval_string(
~s"""
<a href="<%= @route %>">Sign in with <%= @strategy.name %></a>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, :callback, _) when strategy.provider == :oauth2, do: ""
defp render_strategy(strategy, phase, _options) do
inspect({strategy.provider, phase})
end
defp route_for_phase(strategy, phase) do
path =
strategy
|> Strategy.routes()
|> Enum.find(&(elem(&1, 1) == phase))
|> elem(0)
Path.join("/auth", path)
end
end

View file

@ -9,12 +9,14 @@
<%= 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 {resource, options, strategies} <- @resources do %>
<h2><%= inspect(options.subject_name) %> - <%= Ash.Api.Info.short_name(options.api) %> / <%= Ash.Resource.Info.short_name(resource) %></h2>
<%= for provider <- config.providers do %>
<%= Module.concat(provider, Html).request(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}") %>
<%= Module.concat(provider, Html).callback(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}/callback") %>
<%= for strategy <- strategies do %>
<%= for phase <- Strategy.phases(strategy) do %>
<%= render_strategy(strategy, phase, options) %>
<% end %>
<% end %>
<% end %>

View file

@ -1,83 +1,5 @@
defmodule AshAuthentication do
import AshAuthentication.Utils, only: [to_sentence: 2]
@dsl [
%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.
This needs to be unique system-wide and if not set will be inferred
from the resource name (ie `MyApp.Accounts.User` will have a subject
name of `user`).
"""
],
api: [
type: {:behaviour, Ash.Api},
doc: """
The name of the Ash API to use to access this resource when
registering/authenticating.
""",
required: true
],
get_by_subject_action_name: [
type: :atom,
doc: """
The name of the read action used to retrieve records.
Used internally by `AshAuthentication.subject_to_resource/2`. If the
action doesn't exist, one will be generated for you.
""",
default: :get_by_subject
]
]
},
%Spark.Dsl.Section{
name: :tokens,
describe: "Configure JWT settings for this resource",
schema: [
enabled?: [
type: :boolean,
doc: """
Should JWTs be generated by this resource?
""",
default: false
],
signing_algorithm: [
type: :string,
doc: """
The algorithm to use for token signing.
Available signing algorithms are;
#{to_sentence(Joken.Signer.algorithms(), final: "and")}.
""",
default: hd(Joken.Signer.algorithms())
],
token_lifetime: [
type: :pos_integer,
doc: """
How long a token should be valid, in hours.
"""
],
revocation_resource: [
type: {:behaviour, Ash.Resource},
doc: """
The resource used to store token revocation information.
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.
"""
]
]
}
]
import AshAuthentication.Dsl
@moduledoc """
AshAuthentication provides a turn-key authentication solution for folks using
@ -85,17 +7,16 @@ defmodule AshAuthentication do
## Usage
This package assumes that you have [Phoenix](https://phoenixframework.org/) and
[Ash](https://ash-hq.org/) installed and configured. See their individual
documentation for details.
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 one
or more extensions onto your Ash resource:
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, AshAuthentication.PasswordAuthentication]
extensions: [AshAuthentication]
attributes do
uuid_primary_key :id
@ -105,11 +26,13 @@ defmodule AshAuthentication do
authentication do
api MyApp.Accounts
end
password_authentication do
identity_field :email
hashed_password_field :hashed_password
strategies do
password do
identity_field :email
hashed_password_field :hashed_password
end
end
end
identities do
@ -121,41 +44,49 @@ defmodule AshAuthentication do
If you plan on providing authentication via the web, then you will need to
define a plug using
[`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html)
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that
routes incoming authentication requests to the correct provider and provides
callbacks for you to manipulate the conn after success or failure.
## Authentication Providers
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.
Currently the only supported authentication provider is
[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html)
which provides actions for registering and signing in users using an identifier
and a password.
## Authentication Strategies
Planned future providers include:
Currently supported strategies:
* OAuth 1.0
* OAuth 2.0
* OpenID Connect
1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html)
- authenticate users against your local database using a unique identity
(such as username or email address) and a password.
2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html)
- authenticate using local or remote [OAuth 2.0](https://oauth.net/2/)
compatible services.
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
#{Spark.Dsl.Extension.doc_index(dsl())}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
#{Spark.Dsl.Extension.doc(dsl())}
"""
alias Ash.{Api, Query, Resource}
alias Ash.{Api, Error.Query.NotFound, Query, Resource}
alias AshAuthentication.Info
alias Spark.Dsl.Extension
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.Transformer]
sections: dsl(),
transformers: [
AshAuthentication.Transformer,
AshAuthentication.Strategy.Password.Transformer,
AshAuthentication.Strategy.OAuth2.Transformer,
AshAuthentication.Strategy.Confirmation.Transformer
]
require Ash.Query
@ -171,42 +102,40 @@ defmodule AshAuthentication do
@doc """
Find all resources which support authentication for a given OTP application.
Returns a map where the key is the authentication provider, and the values are
lists of api/resource pairs.
Returns a list of resource modules.
## Example
iex> authenticated_resources(:ash_authentication)
[Example.User]
This is primarily useful for introspection, but also allows us to simplify
token lookup.
"""
@spec authenticated_resources(atom) :: [resource_config]
@spec authenticated_resources(atom) :: [Resource.t()]
def authenticated_resources(otp_app) do
otp_app
|> Application.get_env(:ash_apis, [])
|> Stream.flat_map(&Api.Info.resources(&1))
|> Stream.map(&resource_config/1)
|> Stream.reject(&(&1 == :error))
|> Stream.filter(&(AshAuthentication in Spark.extensions(&1)))
|> Enum.to_list()
end
def resource_config(resource) do
resource
|> Extension.get_persisted(:authentication)
|> case do
nil ->
:error
config ->
Map.put(config, :resource, resource)
end
end
@doc """
Return a subject string for an AshAuthentication resource.
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 resource_to_subject(Resource.record()) :: subject
def resource_to_subject(record) do
@spec user_to_subject(Resource.record()) :: subject
def user_to_subject(record) do
subject_name =
record.__struct__
|> AshAuthentication.Info.authentication_subject_name!()
|> Info.authentication_subject_name!()
record.__struct__
|> Resource.Info.primary_key()
@ -216,30 +145,34 @@ defmodule AshAuthentication do
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)
@doc ~S"""
Given a subject string, attempt to retrieve a user record.
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
iex> %{id: user_id} = build_user()
...> {:ok, %{id: ^user_id}} = subject_to_user("user?id=#{user_id}", Example.User)
"""
@spec subject_to_user(subject | URI.t(), Resource.t()) ::
{:ok, Resource.record()} | {:error, any}
def subject_to_user(subject, resource) when is_binary(subject),
do: subject |> URI.parse() |> subject_to_user(resource)
def subject_to_user(%URI{path: subject_name, query: primary_key} = _subject, resource) 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),
{:ok, api} <- Info.authentication_api(resource) do
primary_key =
primary_key
|> URI.decode_query()
|> Enum.to_list()
config.resource
resource
|> Query.for_read(action_name, %{})
|> Query.filter(^primary_key)
|> config.api.read()
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
_ -> {:error, "Invalid subject"}
_ -> {:error, NotFound.exception([])}
end
end
end

View file

@ -6,6 +6,12 @@ defmodule AshAuthentication.BcryptProvider do
@doc """
Given some user input as a string, convert it into it's hashed form using `Bcrypt`.
## Example
iex> {:ok, hashed} = hash("Marty McFly")
...> String.starts_with?(hashed, "$2b$04$")
true
"""
@impl true
@spec hash(String.t()) :: {:ok, String.t()} | :error
@ -14,6 +20,12 @@ defmodule AshAuthentication.BcryptProvider do
@doc """
Check if the user input matches the hash.
## Example
iex> valid?("Marty McFly", "$2b$04$qgacrnrAJz8aPwaVQiGJn.PvryldV.NfOSYYvF/CZAGgMvvzhIE7S")
true
"""
@impl true
@spec valid?(input :: String.t(), hash :: String.t()) :: boolean
@ -22,6 +34,11 @@ defmodule AshAuthentication.BcryptProvider do
@doc """
Simulate a password check to help avoid timing attacks.
## Example
iex> simulate()
false
"""
@impl true
@spec simulate :: false

View file

@ -1,225 +0,0 @@
defmodule AshAuthentication.Confirmation do
@default_lifetime_days 3
@dsl [
%Spark.Dsl.Section{
name: :confirmation,
describe: "User confirmation behaviour",
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the confirmation token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
monitor_fields: [
type: {:list, :atom},
doc: """
A list of fields to monitor for changes (eg `[:email, :phone_number]`).
""",
required: true
],
confirmed_at_field: [
type: :atom,
doc: """
The name of a field to store the time that the last confirmation took place.
This attribute will be dynamically added to the resource if not already present.
""",
default: :confirmed_at
],
confirm_on_create?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a new resource is created?
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a resource is changed?
""",
default: true
],
inhibit_updates?: [
type: :boolean,
doc: """
Wait until confirmation is received before actually changing a monitored field?
If a change to a monitored field is detected, then the change is stored in the confirmation token and the changeset updated to not make the requested change. When the token is confirmed, the change will be applied.
""",
default: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 2}},
doc: """
How to send the confirmation instructions to the user.
Allows you to glue sending of confirmation instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application.
Accepts a module, module and opts, or a function that takes a record, reset token and options.
See `AshAuthentication.Sender` for more information.
""",
required: true
],
confirm_action_name: [
type: :atom,
doc: """
The name of the action to use when performing confirmation.
""",
default: :confirm
]
]
}
]
@moduledoc """
Add a confirmation steps to creates and updates.
This extension provides a mechanism to force users to confirm some of their
details upon create as in your typical "email confirmation" flow.
## Senders
You can set the DSL's `sender` key to be either a three-arity anonymous
function or a module which implements the `AshAuthentication.Sender`
behaviour. This callback can be used to send confirmation instructions to the
user via the system of your choice. See `AshAuthentication.Sender` for more
information.
## Usage
```elixir
defmodule MyApp.Accounts.Users do
use Ash.Resource, extensions: [AshAuthentication.Confirmation]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
confirmation do
monitor_fields [:email]
end
end
```
## Endpoints
A confirmation can be sent to either the `request` or `callback` endpoints.
The only required parameter is `"confirm"` which should contain the
confirmation token.
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
"""
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.Confirmation.Transformer]
use AshAuthentication.Provider
alias Ash.{Changeset, Resource}
alias AshAuthentication.{Confirmation, Jwt}
@doc """
Generate a confirmation token for the changes in the changeset.
## Example
iex> changeset = Ash.Changeset.for_create(MyApp.Accounts.User, :register, %{"email" => "marty@myfly.me", # ... })
...> confirmation_token_for(changeset)
{:ok, "abc123"}
"""
@spec confirmation_token_for(Changeset.t(), Resource.record()) ::
{:ok, String.t()} | {:error, any}
def confirmation_token_for(changeset, user) when changeset.resource == user.__struct__ do
resource = changeset.resource
with true <- enabled?(resource),
{:ok, monitored_fields} <- Confirmation.Info.monitor_fields(resource),
changes <- get_changes(changeset, monitored_fields),
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
{:ok, lifetime} <- Confirmation.Info.token_lifetime(resource),
{:ok, token, _claims} <-
Jwt.token_for_record(user, %{"act" => action, "chg" => changes},
token_lifetime: lifetime
) do
{:ok, token}
else
{:error, reason} -> {:error, reason}
_ -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
end
end
defp get_changes(changeset, monitored_fields) do
monitored_fields
|> Enum.filter(&Changeset.changing_attribute?(changeset, &1))
|> Enum.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
end
@doc """
Confirm a creation or change.
## Example
iex> confirm(MyApp.Accounts.User, %{"confirm" => "abc123"})
{:ok, user}
"""
@spec confirm(Resource.t(), params) :: {:ok, Resource.record()} | {:error, any}
when params: %{required(String.t()) => String.t()}
def confirm(resource, params) do
with true <- enabled?(resource),
{:ok, token} <- Map.fetch(params, "confirm"),
{:ok, %{"sub" => subject}} <- Jwt.peek(token),
config <- AshAuthentication.resource_config(resource),
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
user
|> Changeset.for_update(action, %{"confirm" => token})
|> api.update()
else
false -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
{:ok, _} -> {:error, "Invalid confirmation token"}
:error -> {:error, "Invalid confirmation token"}
{:error, reason} -> {:error, reason}
end
end
@doc """
Handle the callback phase.
Handles confirmation via the same endpoint.
"""
@impl true
defdelegate callback_plug(conn, opts), to: Confirmation.Plug, as: :handle
@doc """
Handle the request phase.
Handles confirmation via the same endpoint.
"""
@impl true
defdelegate request_plug(conn, opts), to: Confirmation.Plug, as: :handle
@doc false
@impl true
def provides(_), do: "confirm"
end

View file

@ -1,35 +0,0 @@
defmodule AshAuthentication.Confirmation.ConfirmChange do
@moduledoc """
Performs a change based on the contents of a confirmation token.
"""
use Ash.Resource.Change
alias AshAuthentication.{Confirmation.Info, Jwt}
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 ->
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
{:ok, %{"act" => token_action, "chg" => changes}, _} <-
Jwt.verify(token, changeset.resource),
{:ok, resource_action} <- Info.confirm_action_name(changeset.resource),
true <- to_string(resource_action) == token_action,
{:ok, allowed_fields} <- Info.monitor_fields(changeset.resource),
{:ok, confirmed_at} <- Info.confirmed_at_field(changeset.resource) do
allowed_changes =
changes
|> Map.take(Enum.map(allowed_fields, &to_string/1))
changeset
|> Changeset.change_attributes(allowed_changes)
|> Changeset.change_attribute(confirmed_at, DateTime.utc_now())
else
_ -> {:error, InvalidArgument.exception(field: :confirm, message: "is not valid")}
end
end)
end
end

View file

@ -1,88 +0,0 @@
defmodule AshAuthentication.Confirmation.ConfirmationHookChange do
@moduledoc """
Triggers a confirmation flow when one of the monitored fields is changed.
Optionally inhibits changes to monitored fields on update.
"""
use Ash.Resource.Change
alias AshAuthentication.{Confirmation, Confirmation.Info}
alias Ash.{Changeset, Resource.Change}
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
changeset
|> Changeset.before_action(fn changeset ->
options = Info.options(changeset.resource)
changeset
|> not_confirm_action(options)
|> should_confirm_action_type(options)
|> monitored_field_changing(options)
|> changes_would_be_valid()
|> maybe_inhibit_updates(options)
|> maybe_perform_confirmation(options, changeset)
end)
end
defp not_confirm_action(changeset, options)
when changeset.action != options.confirm_action_name,
do: changeset
defp not_confirm_action(_changeset, _options), do: nil
defp should_confirm_action_type(changeset, options)
when changeset.action_type == :create and options.confirm_on_create?,
do: changeset
defp should_confirm_action_type(changeset, options)
when changeset.action_type == :update and options.confirm_on_update?,
do: changeset
defp should_confirm_action_type(_changeset, _options), do: nil
defp monitored_field_changing(nil, _options), do: nil
defp monitored_field_changing(changeset, options) do
if Enum.any?(options.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
do: changeset,
else: nil
end
defp changes_would_be_valid(changeset) when changeset.valid?, do: changeset
defp changes_would_be_valid(_), do: nil
defp maybe_inhibit_updates(changeset, options)
when changeset.action_type == :update and options.inhibit_updates? do
options.monitor_fields
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
end
defp maybe_inhibit_updates(changeset, _options), do: changeset
defp maybe_perform_confirmation(nil, _options, original_changeset), do: original_changeset
defp maybe_perform_confirmation(changeset, options, original_changeset) do
changeset
|> Changeset.after_action(fn _changeset, user ->
original_changeset
|> Confirmation.confirmation_token_for(user)
|> case do
{:ok, token} ->
{sender, send_opts} = options.sender
sender.send(user, token, send_opts)
metadata =
user.__metadata__
|> Map.put(:confirmation_token, token)
{:ok, %{user | __metadata__: metadata}}
_ ->
{:ok, user}
end
end)
end
end

View file

@ -1,65 +0,0 @@
defmodule AshAuthentication.Confirmation.Html do
@moduledoc """
Renders a very basic form for handling a confirmation token.
These are mainly used for testing, and you should instead write your own or
use the widgets in `ash_authentication_phoenix`.
"""
require EEx
alias AshAuthentication.Confirmation
EEx.function_from_string(
:defp,
:render,
~s"""
<form method="<%= @method %>" action="<%= @action %>">
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="text" name="confirm" placeholder="Confirmation token" />
<br />
<input type="submit" value="Confirm" />
</fieldset>
</form>
""",
[:assigns]
)
@defaults [method: "POST", legend: "Confirm"]
@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 false
@spec callback(module, options) :: String.t()
def callback(_module, _options), do: ""
@doc """
Render a basic HTML confirmation form.
"""
@spec request(module, options) :: String.t()
def request(resource, options) do
resource
|> build_assigns(options)
|> render()
end
defp build_assigns(resource, options) do
@defaults
|> Keyword.merge(options)
|> Map.new()
|> Map.merge(Confirmation.Info.options(resource))
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
end
end

View file

@ -1,9 +0,0 @@
defmodule AshAuthentication.Confirmation.Info do
@moduledoc """
Generated configuration functions based on a resource's DSL configuration.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.Confirmation,
sections: [:confirmation]
end

View file

@ -1,23 +0,0 @@
defmodule AshAuthentication.Confirmation.Plug do
@moduledoc """
Handlers for incoming HTTP requests.
"""
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
alias AshAuthentication.Confirmation
alias Plug.Conn
@doc """
Handle an inbound confirmation request.
"""
@spec handle(Conn.t(), any) :: Conn.t()
def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do
case Confirmation.confirm(config.resource, params) do
{:ok, user} ->
private_store(conn, {:success, user})
{:error, reason} ->
private_store(conn, {:failure, reason})
end
end
end

View file

@ -1,222 +0,0 @@
defmodule AshAuthentication.Confirmation.Transformer do
@moduledoc """
The Confirmation transformer.
Scans the resource and checks that all the fields and actions needed are present.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.Confirmation.{
ConfirmationHookChange,
ConfirmChange,
Info
}
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Sender}
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 transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
with :ok <- validate_extension(dsl_state, AshAuthentication),
:ok <- validate_token_generation_enabled(dsl_state),
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
:ok <- validate_behaviour(sender, Sender),
:ok <- validate_monitor_fields(dsl_state),
{:ok, action_name} <- Info.confirm_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(dsl_state, action_name, &build_confirm_action(&1, action_name)),
:ok <- validate_confirm_action(dsl_state, action_name),
{:ok, confirmed_at} <- Info.confirmed_at_field(dsl_state),
{:ok, dsl_state} <-
maybe_build_attribute(
dsl_state,
confirmed_at,
&build_confirmed_at_attribute(&1, confirmed_at)
),
:ok <- validate_confirmed_at_attribute(dsl_state),
{:ok, dsl_state} <- maybe_build_change(dsl_state, ConfirmationHookChange) do
authentication =
Transformer.get_persisted(dsl_state, :authentication)
|> Map.update(
:providers,
[AshAuthentication.Confirmation],
&[AshAuthentication.Confirmation | &1]
)
dsl_state =
dsl_state
|> Transformer.persist(:authentication, authentication)
{:ok, dsl_state}
else
:error -> {:error, "Configuration error"}
{:error, reason} -> {:error, reason}
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 validate_confirmed_at_attribute(dsl_state) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, field_name} <- Info.confirmed_at_field(dsl_state),
{:ok, attribute} <- find_attribute(dsl_state, field_name),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]),
:ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do
:ok
else
:error ->
{:error,
DslError.exception(
path: [:confirmation],
message: "The `confirmed_at_field` option must be set."
)}
{:error, reason} ->
{:error, reason}
end
end
defp validate_monitor_fields(dsl_state) do
case Info.monitor_fields(dsl_state) do
{:ok, [_ | _] = fields} ->
Enum.reduce_while(fields, :ok, &validate_monitored_field_reducer(dsl_state, &1, &2))
_ ->
{:error,
DslError.exception(
path: [:confirmation],
message:
"The `AshAuthentication.Confirmation` extension requires at least one monitored field to be configured."
)}
end
end
defp validate_monitored_field_reducer(dsl_state, field, _) do
case validate_monitored_field(dsl_state, field) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end
defp validate_monitored_field(dsl_state, field) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, attribute} <- find_attribute(dsl_state, field),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
maybe_validate_eager_checking(dsl_state, field, resource)
end
end
defp maybe_validate_eager_checking(dsl_state, field, resource) do
if Info.inhibit_updates?(dsl_state) do
dsl_state
|> Resource.Info.identities()
|> Enum.find(&(&1.keys == [field]))
|> case do
%{eager_check_with: nil} ->
{:error,
DslError.exception(
path: [:identities, :identity],
message:
"The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated."
)}
_ ->
:ok
end
else
:ok
end
end
defp build_confirm_action(dsl_state, action_name) do
with {:ok, fields} <- Info.monitor_fields(dsl_state) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
name: :confirm,
type: Type.String,
allow_nil?: false
)
]
changes = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: ConfirmChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: GenerateTokenChange
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: action_name,
accept: fields,
arguments: arguments,
changes: changes
)
end
end
defp maybe_build_attribute(dsl_state, attribute_name, builder) do
with {:error, _} <- find_attribute(dsl_state, attribute_name),
{:ok, attribute} <- builder.(dsl_state) do
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
else
{:ok, attribute} when is_struct(attribute, Resource.Attribute) -> {:ok, dsl_state}
{:error, reason} -> {:error, reason}
end
end
defp build_confirmed_at_attribute(_dsl_state, attribute_name) do
Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
name: attribute_name,
type: Type.UtcDatetimeUsec,
allow_nil?: true,
writable?: true
)
end
defp maybe_build_change(dsl_state, change_module) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
changes <- Resource.Info.changes(resource),
false <- change_module in changes,
{:ok, change} <-
Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
else
true -> {:ok, dsl_state}
{:error, reason} -> {:error, reason}
end
end
defp validate_confirm_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_has_change(action, ConfirmChange),
:ok <- validate_action_argument_option(action, :confirm, :type, [Type.String]) do
validate_action_argument_option(action, :confirm, :allow_nil?, [false])
end
end
end

View file

@ -0,0 +1,532 @@
defmodule AshAuthentication.Dsl do
@moduledoc false
###
### Only exists to move the DSL out of `AshAuthentication` to aid readability.
###
import AshAuthentication.Utils, only: [to_sentence: 2]
import Joken.Signer, only: [algorithms: 0]
alias Ash.{Api, Resource}
alias AshAuthentication.{
Strategy.Confirmation,
Strategy.OAuth2,
Strategy.Password
}
alias Spark.{
Dsl.Entity,
Dsl.Section,
OptionsHelpers
}
@shared_strategy_options [
name: [
type: :atom,
doc: """
Uniquely identifies the strategy.
""",
required: true
]
]
@default_lifetime_days 14
@secret_type {:or,
[
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 2}},
:string
]}
@secret_doc """
Takes either a module which implements the `AshAuthentication.Secret`
behaviour, a 2 arity anonymous function or a string.
See the module documentation for `AshAuthentication.Secret` for more
information.
"""
@doc false
@spec dsl :: [Section.t()]
def dsl do
[
%Section{
name: :authentication,
describe: "Configure authentication for this resource",
schema: [
subject_name: [
type: :atom,
doc: """
The subject name is used anywhere that a short version of your
resource name is needed, eg:
- generating token claims,
- generating routes,
- form parameter nesting.
This needs to be unique system-wide and if not set will be inferred
from the resource name (ie `MyApp.Accounts.User` will have a subject
name of `user`).
"""
],
api: [
type: {:behaviour, Api},
doc: """
The name of the Ash API to use to access this resource when
doing anything authenticaiton related.
""",
required: true
],
get_by_subject_action_name: [
type: :atom,
doc: """
The name of the read action used to retrieve records.
Used internally by `AshAuthentication.subject_to_user/2`. If the
action doesn't exist, one will be generated for you.
""",
default: :get_by_subject
]
],
sections: [
%Section{
name: :tokens,
describe: "Configure JWT settings for this resource",
schema: [
enabled?: [
type: :boolean,
doc: """
Should JWTs be generated by this resource?
""",
default: false
],
signing_algorithm: [
type: :string,
doc: """
The algorithm to use for token signing.
Available signing algorithms are;
#{to_sentence(algorithms(), final: "and")}.
""",
default: hd(algorithms())
],
token_lifetime: [
type: :pos_integer,
doc: """
How long a token should be valid, in hours.
Since refresh tokens are not yet supported, you should
probably set this to a reasonably long time to ensure
a good user experience.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
revocation_resource: [
type: {:behaviour, Resource},
doc: """
The resource used to store token revocation information.
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.
"""
]
]
},
%Section{
name: :strategies,
describe: "Configure authentication strategies on this resource",
entities: [
strategy(:password),
strategy(:oauth2),
strategy(:confirmation)
]
}
]
}
]
end
def strategy(:password) do
%Entity{
name: :password,
describe: "Strategy for authenticating using local resources as the source of truth.",
examples: [
"""
password :password do
identity_field :email
hashed_password_field :hashed_password
hash_provider AshAuthentication.BcryptProvider
confirmation_required? true
end
"""
],
args: [:name],
hide: [:name],
target: Password,
schema:
OptionsHelpers.merge_schemas(
[
identity_field: [
type: :atom,
doc: """
The name of the attribute which uniquely identifies the user.
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 user'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.
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.
If not present it will be generated by prepending the strategy name
with `register_with_`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: """
The name to use for the sign in action.
If not present it will be generated by prependign the strategy name
with `sign_in_with_`.
""",
required: false
]
],
@shared_strategy_options,
"Shared options"
),
entities: [resettable: [Password.Resettable.entity()]]
}
end
def strategy(:oauth2) do
%Entity{
name: :oauth2,
describe: "OAuth2 authentication",
args: [:name],
target: OAuth2,
schema:
OptionsHelpers.merge_schemas(
[
client_id: [
type: @secret_type,
doc: """
The OAuth2 client ID.
#{@secret_doc}
Example:
```elixir
client_id fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_client_id)
end
```
""",
required: true
],
site: [
type: @secret_type,
doc: """
The base URL of the OAuth2 server - including the leading protocol
(ie `https://`).
#{@secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: true
],
auth_method: [
type:
{:in,
[
nil,
:client_secret_basic,
:client_secret_post,
:client_secret_jwt,
:private_key_jwt
]},
doc: """
The authentication strategy used, optional. If not set, no
authentication will be used during the access token request. The
value may be one of the following:
* `:client_secret_basic`
* `:client_secret_post`
* `:client_secret_jwt`
* `:private_key_jwt`
""",
default: :client_secret_post
],
client_secret: [
type: @secret_type,
doc: """
The OAuth2 client secret.
Required if :auth_method is `:client_secret_basic`,
`:client_secret_post` or `:client_secret_jwt`.
#{@secret_doc}
Example:
```elixir
site fn _, resource ->
:my_app
|> Application.get_env(resource, [])
|> Keyword.fetch(:oauth_site)
end
```
""",
required: false
],
authorize_path: [
type: @secret_type,
doc: """
The API path to the OAuth2 authorize endpoint.
Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:authorize_path))}`.
#{@secret_doc}
Example:
```elixir
authorize_path fn _, _ -> {:ok, "/authorize"} end
```
""",
required: false
],
token_path: [
type: @secret_type,
doc: """
The API path to access the token endpoint.
Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:token_path))}`.
#{@secret_doc}
Example:
```elixir
token_path fn _, _ -> {:ok, "/oauth_token"} end
```
""",
required: false
],
user_path: [
type: @secret_type,
doc: """
The API path to access the user endpoint.
Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:user_path))}`.
#{@secret_doc}
Example:
```elixir
user_path fn _, _ -> {:ok, "/userinfo"} end
```
""",
required: false
],
private_key: [
type: @secret_type,
doc: """
The private key to use if `:auth_method` is `:private_key_jwt`
#{@secret_doc}
""",
required: false
],
redirect_uri: [
type: @secret_type,
doc: """
The callback URI base.
Not the whole URI back to the callback endpoint, but the URI to your
`AuthPlug`. We can generate the rest.
Whilst not particularly secret, it seemed prudent to allow this to be
configured dynamically so that you can use different URIs for
different environments.
#{@secret_doc}
""",
required: true
],
authorization_params: [
type: :keyword_list,
doc: """
Any additional parameters to encode in the request phase.
eg: `authorization_params scope: "openid profile email"`
""",
default: []
],
registration_enabled?: [
type: :boolean,
doc: """
Is registration enabled for this provider?
If this option is enabled, then new users will be able to register for
your site when authenticating and not already present.
If not, then only existing users will be able to authenticate.
""",
default: true
],
register_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to register a user.
Only needed if `registration_enabled?` is `true`.
Because we we don't know the response format of the server, you must
implement your own registration action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name eg:
`register_with_#{name}`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to sign in an existing user.
Only needed if `registration_enabled?` is `false`.
Because we don't know the response format of the server, you must
implement your own sign-in action of the same name.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the strategy name, eg:
`sign_in_with_#{name}`.
""",
required: false
],
identity_resource: [
type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
doc: """
The resource used to store user identities.
Given that a user can be signed into multiple different
authentication providers at once we use the
`AshAuthentication.UserIdentity` resource to build a mapping
between users, providers and that provider's uid.
See the Identities section of the module documentation for more
information.
Set to `false` to disable.
""",
default: false
],
identity_relationship_name: [
type: :atom,
doc: "Name of the relationship to the provider identities resource",
default: :identities
],
identity_relationship_user_id_attribute: [
type: :atom,
doc: """
The name of the destination (user_id) attribute on your provider
identity resource.
The only reason to change this would be if you changed the
`user_id_attribute_name` option of the provider identity.
""",
default: :user_id
]
],
@shared_strategy_options,
"Shared options"
)
}
end
def strategy(:confirmation) do
%Entity{
name: :confirmation,
describe: "User confirmation flow",
target: Confirmation,
schema: Confirmation.schema()
}
end
end

View file

@ -5,6 +5,8 @@ defmodule AshAuthentication.Errors.AuthenticationFailed do
use Ash.Error.Exception
def_ash_error([], class: :forbidden)
@type t :: Exception.t()
defimpl Ash.ErrorKind do
@moduledoc false
def id(_), do: Ecto.UUID.generate()

View file

@ -0,0 +1,15 @@
defmodule AshAuthentication.Errors.InvalidToken do
@moduledoc """
An invalid token was presented.
"""
use Ash.Error.Exception
def_ash_error([:type], class: :forbidden)
defimpl Ash.ErrorKind do
@moduledoc false
def id(_), do: Ecto.UUID.generate()
def code(_), do: "invalid_token"
def message(%{type: nil}), do: "Invalid token"
def message(%{type: type}), do: "Invalid #{type} token"
end
end

View file

@ -0,0 +1,17 @@
defmodule AshAuthentication.Errors.MissingSecret do
@moduledoc """
A secret is now missing.
"""
use Ash.Error.Exception
def_ash_error([:resource], class: :forbidden)
defimpl Ash.ErrorKind do
@moduledoc false
def id(_), do: Ecto.UUID.generate()
def code(_), do: "missing_secret"
def message(%{path: path, resource: resource}),
do:
"Secret for `#{Enum.join(path, ".")}` on the `#{inspect(resource)}` resource is not accessible."
end
end

View file

@ -13,8 +13,8 @@ defmodule AshAuthentication.GenerateTokenChange do
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)
if Info.authentication_tokens_enabled?(result.__struct__) do
{:ok, token, _claims} = Jwt.token_for_user(result)
{:ok, %{result | __metadata__: Map.put(result.__metadata__, :token, token)}}
else
{:ok, result}

View file

@ -5,6 +5,33 @@ defmodule AshAuthentication.Info do
use AshAuthentication.InfoGenerator,
extension: AshAuthentication,
sections: [:authentication, :tokens],
prefix?: true
sections: [:authentication]
@doc """
Retrieve a named strategy from a resource.
"""
@spec strategy(dsl_or_resource :: map | module, atom) :: {:ok, strategy} | :error
when strategy: struct
def strategy(dsl_or_resource, name) do
dsl_or_resource
|> authentication_strategies()
|> Enum.find_value(:error, fn strategy ->
if strategy.name == name, do: {:ok, strategy}
end)
end
@doc """
Retrieve a named strategy from a resource (raising version).
"""
@spec strategy!(dsl_or_resource :: map | module, atom) :: strategy | no_return
when strategy: struct
def strategy!(dsl_or_resource, name) do
case strategy(dsl_or_resource, name) do
{:ok, strategy} ->
strategy
:error ->
raise "No strategy named `#{inspect(name)}` found on resource `#{inspect(dsl_or_resource)}`"
end
end
end

View file

@ -12,14 +12,13 @@ defmodule AshAuthentication.InfoGenerator do
```
"""
@type options :: [{:extension, module} | {:sections, [atom]} | {:prefix?, boolean}]
@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, [])
prefix? = Keyword.get(opts, :prefix?, false)
quote do
require AshAuthentication.InfoGenerator
@ -27,14 +26,17 @@ defmodule AshAuthentication.InfoGenerator do
AshAuthentication.InfoGenerator.generate_config_functions(
unquote(extension),
unquote(sections),
unquote(prefix?)
unquote(sections)
)
AshAuthentication.InfoGenerator.generate_options_functions(
unquote(extension),
unquote(sections),
unquote(prefix?)
unquote(sections)
)
AshAuthentication.InfoGenerator.generate_entity_functions(
unquote(extension),
unquote(sections)
)
end
end
@ -44,17 +46,14 @@ defmodule AshAuthentication.InfoGenerator do
which returns a map of all configured options for a resource (including
defaults).
"""
@spec generate_options_functions(module, [atom], boolean) :: Macro.t()
defmacro generate_options_functions(_extension, sections, false) when length(sections) > 1,
do: raise("Cannot generate options functions for more than one section without prefixes.")
defmacro generate_options_functions(extension, sections, prefix?) do
for {section, options} <- extension_sections_to_list(extension, sections) do
function_name = if prefix?, do: :"#{section}_options", else: :options
@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(section)} DSL options
#{unquote(Enum.join(path, "."))} DSL options
Returns a map containing the and any configured or default values.
"""
@ -66,7 +65,7 @@ defmodule AshAuthentication.InfoGenerator do
|> Stream.map(fn option ->
value =
dsl_or_resource
|> get_opt([option.section], option.name, Map.get(option, :default))
|> get_opt(option.path, option.name, Map.get(option, :default))
{option.name, value}
end)
@ -78,25 +77,66 @@ defmodule AshAuthentication.InfoGenerator do
end
@doc """
Given an extension and a list of DSL sections generate individual config
functions for each option.
Given an extension and a list of DSL sections, generate an entities function
which returns a list of entities.
"""
@spec generate_config_functions(module, [atom], boolean) :: Macro.t()
defmacro generate_config_functions(extension, sections, prefix?) do
for {_, options} <- extension_sections_to_list(extension, sections) do
for option <- options do
function_name = if prefix?, do: :"#{option.section}_#{option.name}", else: option.name
@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))
option
|> Map.put(:function_name, function_name)
|> generate_config_function()
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
defp extension_sections_to_list(extension, sections) do
@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.map(fn section ->
|> 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} ->
@ -106,26 +146,35 @@ defmodule AshAuthentication.InfoGenerator do
|> Map.update!(:type, &spec_for_type/1)
|> Map.put(:pred?, name |> to_string() |> String.ends_with?("?"))
|> Map.put(:name, name)
|> Map.put(:section, section.name)
|> Map.put(:path, path)
|> Map.put(
:function_name,
path
|> Enum.concat([name])
|> Enum.join("_")
|> String.trim_trailing("?")
|> String.to_atom()
)
end)
{section.name, schema}
{path, schema}
end)
|> Map.new()
|> Map.take(sections)
end
defp generate_config_function(%{pred?: true} = option) do
function_name = :"#{option.function_name}?"
quote location: :keep do
@doc unquote(option.doc)
@spec unquote(option.function_name)(dsl_or_resource :: module | map) ::
@spec unquote(function_name)(dsl_or_resource :: module | map) ::
unquote(option.type)
def unquote(option.function_name)(dsl_or_resource) do
def unquote(function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4]
get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.path),
unquote(option.name),
unquote(option.default)
)
@ -144,7 +193,7 @@ defmodule AshAuthentication.InfoGenerator do
case get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.path),
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do
@ -162,7 +211,7 @@ defmodule AshAuthentication.InfoGenerator do
case get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.path),
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do

View file

@ -1,6 +1,6 @@
defmodule AshAuthentication.Jwt do
@default_algorithm "HS256"
@default_lifetime_hrs 7 * 24
@default_lifetime_days 7
@supported_algorithms Joken.Signer.algorithms()
import AshAuthentication.Utils, only: [to_sentence: 2]
@ -24,7 +24,7 @@ defmodule AshAuthentication.Jwt do
config :ash_authentication, #{inspect(__MODULE__)},
signing_algorithm: #{inspect(@default_algorithm)}
signing_secret: "I finally invent something that works!",
token_lifetime: #{@default_lifetime_hrs} # #{@default_lifetime_hrs / 24.0} days
token_lifetime: #{@default_lifetime_days * 24} # #{@default_lifetime_days} days
```
Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
@ -34,12 +34,12 @@ defmodule AshAuthentication.Jwt do
[`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
The default token lifetime is #{@default_lifetime_days * 24} and should be specified
in integer positive hours.
"""
alias Ash.Resource
alias AshAuthentication.Jwt.Config
alias AshAuthentication.{Info, Jwt.Config}
@typedoc """
A string likely to contain a valid JWT.
@ -62,32 +62,35 @@ defmodule AshAuthentication.Jwt do
@doc "The default token lifetime"
@spec default_lifetime_hrs :: pos_integer
def default_lifetime_hrs, do: @default_lifetime_hrs
def default_lifetime_hrs, do: @default_lifetime_days * 24
@doc """
Given a record, generate a signed JWT for use while authenticating.
Given a user, generate a signed JWT for use while authenticating.
"""
@spec token_for_record(Resource.record(), extra_claims :: %{}, options :: keyword) ::
@spec token_for_user(Resource.record(), extra_claims :: %{}, options :: keyword) ::
{:ok, token, claims} | :error
def token_for_record(record, extra_claims \\ %{}, opts \\ []) do
resource = record.__struct__
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
resource = user.__struct__
default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource, opts)
subject = AshAuthentication.resource_to_subject(record)
subject = AshAuthentication.user_to_subject(user)
extra_claims =
extra_claims
|> Map.put("sub", subject)
extra_claims =
case Map.fetch(record.__metadata__, :tenant) do
case Map.fetch(user.__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)
case Joken.generate_and_sign(default_claims, extra_claims, signer) do
{:ok, token, claims} -> {:ok, token, claims}
{:error, _reason} -> :error
end
end
@doc """
@ -99,11 +102,10 @@ defmodule AshAuthentication.Jwt do
@doc """
Given a token, verify it's signature and validate it's claims.
"""
@spec verify(token, Ash.Resource.t() | module) ::
{:ok, claims, AshAuthentication.resource_config()} | :error
@spec verify(token, Resource.t() | atom) :: {:ok, claims, Resource.t()} | :error
def verify(token, otp_app_or_resource) do
if function_exported?(otp_app_or_resource, :spark_is, 0) &&
otp_app_or_resource.spark_is() == Ash.Resource do
otp_app_or_resource.spark_is() == Resource do
verify_for_resource(token, otp_app_or_resource)
else
verify_for_otp_app(token, otp_app_or_resource)
@ -111,24 +113,23 @@ defmodule AshAuthentication.Jwt do
end
defp verify_for_resource(token, resource) do
with config <- AshAuthentication.resource_config(resource),
signer <- Config.token_signer(resource),
with signer <- Config.token_signer(resource),
{:ok, claims} <- Joken.verify(token, signer),
defaults <- Config.default_claims(resource),
{:ok, claims} <- Joken.validate(defaults, claims, config) do
{:ok, claims, config}
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
{:ok, claims, resource}
else
_ -> :error
end
end
defp verify_for_otp_app(token, otp_app) do
with {:ok, config} <- token_to_resource(token, otp_app),
signer <- Config.token_signer(config.resource),
with {:ok, resource} <- token_to_resource(token, otp_app),
signer <- Config.token_signer(resource),
{:ok, claims} <- Joken.verify(token, signer),
defaults <- Config.default_claims(config.resource),
{:ok, claims} <- Joken.validate(defaults, claims, config) do
{:ok, claims, config}
defaults <- Config.default_claims(resource),
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
{:ok, claims, resource}
else
_ -> :error
end
@ -142,21 +143,23 @@ defmodule AshAuthentication.Jwt do
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
@spec token_to_resource(token, module) :: {:ok, Resource.t()} | :error
def token_to_resource(token, otp_app) do
with {:ok, %{"sub" => subject}} <- peek(token),
%URI{path: subject_name} <- URI.parse(subject) do
config_for_subject_name(subject_name, otp_app)
resource_for_subject_name(subject_name, otp_app)
else
_ -> :error
end
end
defp config_for_subject_name(subject_name, otp_app) do
defp resource_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}
|> Enum.find_value(:error, fn resource ->
with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource),
true <- subject_name == to_string(resource_subject_name),
do: {:ok, resource}
end)
end
end

View file

@ -93,9 +93,9 @@ defmodule AshAuthentication.Jwt.Config do
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
@spec validate_jti(String.t(), any, Resource.t() | any) :: boolean
def validate_jti(jti, _claims, resource) when is_atom(resource) do
case Info.authentication_tokens_revocation_resource(resource) do
{:ok, revocation_resource} ->
TokenRevocation.valid?(revocation_resource, jti)
@ -138,7 +138,7 @@ defmodule AshAuthentication.Jwt.Config do
defp config(resource) do
config =
resource
|> Info.tokens_options()
|> Info.authentication_tokens_options()
|> Enum.reject(&is_nil(elem(&1, 1)))
:ash_authentication

View file

@ -1,423 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication do
@dsl [
%Spark.Dsl.Section{
name: :oauth2_authentication,
describe: """
Configure generic OAuth2 authentication for this resource.
""",
schema: [
provider_name: [
type: :atom,
doc: """
A short name for the authentication provider.
Used in routes, etc.
""",
default: :oauth2
],
client_id: [
type:
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 3}},
doc: """
The OAuth2 client ID.
Takes either a 2..3 arity anonymous function, or a module which
implements the `AshAuthentication.Secret` behaviour.
See the module documentation for `AshAuthentication.Secret` for more
information.
""",
required: true
],
site: [
type:
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 3}},
doc: """
The base URL of the OAuth2 server.
Takes either a 2..3 arity anonymous function, or a module which
implements the `AshAuthentication.Secret` behaviour.
See the module documentation for `AshAuthentication.Secret` for more
information.
""",
required: true
],
auth_method: [
type:
{:in,
[
nil,
:client_secret_basic,
:client_secret_post,
:client_secret_jwt,
:private_key_jwt
]},
doc: """
The authentication strategy used, optional. If not set, no
authentication will be used during the access token request. The
value may be one of the following:
* `:client_secret_basic`
* `:client_secret_post`
* `:client_secret_jwt`
* `:private_key_jwt`
""",
default: :client_secret_post
],
client_secret: [
type:
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 3}},
doc: """
The OAuth2 client secret.
Takes either a 2..3 arity anonymous function, or a module which
implements the `AshAuthentication.Secret` behaviour.
See the module documentation for `AshAuthentication.Secret` for more
information.
Required if :auth_method is `:client_secret_basic`, `:client_secret_post` or `:client_secret_jwt`.
""",
required: false
],
authorize_path: [
type: :string,
doc: "The API path to the OAuth2 authorize endpoint.",
default: "/authorize"
],
token_path: [
type: :string,
doc: "The API path to access the token endpoint.",
default: "/oauth/access_token"
],
user_path: [
type: :string,
doc: "The API path to access the user endpoint.",
default: "/user"
],
private_key: [
type:
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 3}},
doc: """
The private key to use if `:auth_method` is `:private_key_jwt`
Takes either a 2..3 arity anonymous function, or a module which
implements the `AshAuthentication.Secret` behaviour.
See the module documentation for `AshAuthentication.Secret` for more
information.
""",
required: false
],
redirect_uri: [
type:
{:spark_function_behaviour, AshAuthentication.Secret,
{AshAuthentication.SecretFunction, 3}},
doc: """
The callback URI base.
Not the whole URI back to the callback endpoint, but the URI to your
`AuthPlug`. We can generate the rest.
Whilst not particularly secret, it seemed prudent to allow this to be
configured dynamically so that you can use different URIs for
different environments.
Takes either a 2..3 arity anonymous function, or a module which
implements the `AshAuthentication.Secret` behaviour.
See the module documentation for `AshAuthentication.Secret` for more information.
""",
required: true
],
authorization_params: [
type: :keyword_list,
doc: """
Any additional parameters to encode in the request phase.
eg: `authorization_params scope: "openid profile email"`
""",
default: []
],
registration_enabled?: [
type: :boolean,
doc: """
""",
default: true
],
sign_in_enabled?: [
type: :boolean,
doc: """
""",
default: false
],
register_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to register a user.
Because we we don't know the response format of the server, you must
implement your own registration action of the same name. Set to
`false` to disable registration of new users.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the `provider_name` eg:
`register_with_#{provider_name}`.
""",
required: false
],
sign_in_action_name: [
type: :atom,
doc: ~S"""
The name of the action to use to sign in an existing user.
Because we don't know the response format of the server, you must
implement your own sign-in action of the same name. Set to `false`
to disable signing in of existing users.
See the "Registration and Sign-in" section of the module
documentation for more information.
The default is computed from the `provider_name`, eg:
`sign_in_with_#{provider_name}`.
""",
required: false
],
identity_resource: [
type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
doc: """
The resource used to store user identities.
Given that a user can be signed into multiple different
authentication providers at once we use the
`AshAuthentication.ProviderIdentity` resource to build a mapping
between users, providers and that provider's uid.
See the Identities section of the module documentation for more
information.
Set to `false` to disable.
""",
default: false
],
identity_relationship_name: [
type: :atom,
doc: "Name of the relationship to the provider identities resource",
default: :identities
],
identity_relationship_user_id_attribute: [
type: :atom,
doc: """
The name of the destination (user_id) attribute on your provider identity resource.
The only reason to change this would be if you changed the
`user_id_attribute_name` option of the provider identity.
""",
default: :user_id
]
]
}
]
@moduledoc """
Authentication using an external OAuth2 server as the source of truth.
This extension provides support for authenticating to a generic OAuth2 server.
Use this if a service-specific strategy is not available for your
authentication provider.
## Usage
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?
end
oauth2_authentication do
client_id fn _, _, _ ->
Application.fetch_env(:my_app, :oauth2_client_id)
end
client_secret fn _, _, _ ->
Application.fetch_env(:my_app, :oauth2_client_secret)
end
site fn _, _, _ ->
{:ok, "https://auth.example.com"}
end)
redirect_uri fn _, _, _ ->
{:ok, "https://localhost:4000/auth"}
end
end
actions do
create :oauth2_register do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
change AshAuthentication.GenerateTokenChange
change MyApp.RegisterUser
end
end
end
```
## Identities
Given that it's possible for a user to be authenticated with more than one
OAuth2 provider, we provide the `AshAuthentication.ProviderIdentity`
extension. This extension dynamically generates a resource which can be used
to keep track of which providers a user has authenticated with, and stores any
tokens they may have in case you wish to make requests to the service on
behalf of the user.
Additionally, for some providers, the provider identity resource can handle
refreshing of access tokens before they expire.
## Registration and Sign-in
You can operate your OAuth2 authentication in either registration or sign-in
mode. You do this by setting one of either `registration_enabled?` or
`sign_in_enabled?` to `true`.
### Registration
When registration is enabled you will need to define a create action (see the
`register_action_name` option for details).
This action will be called when a user successfully authenticates with the
remote authentication provider and it will be passed two arguments:
* `user_info` which contains the [response from the OAuth2 user info
endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse).
* `oauth_tokens` which the [OAuth2 token
response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse).
Add a change to this action which can use this information to build a user
record, eg:
```elixir
create :register_with_oauth2 do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :unique_email
change fn changeset, _ ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
changeset
|> Ash.Changeset.change_attribute(:email, user_info["email"])
end
end
```
There are likely to be additional change modules required depending on your
configuration options. These will be validated at compile time.
### Sign-in
When registration is disabled, you will need to define a sign-in action (see
the `sign_in_action_name` option for details).
This action will be called with the same `user_info` and `oauth_tokens`
arguments as the register action. You use this action to query for an
existing user that matches your criteria, eg:
```elixir
read :sign_in_with_oauth2 do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.OAuth2Authentication.SignInPreparation
filter expr(email == get_path(^arg(:user_info), [:email]))
end
```
## Endpoints
This provider provides both `request` and `callback` endpoints to handle both
phases of the request cycle.
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
"""
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.OAuth2Authentication.Transformer]
use AshAuthentication.Provider
alias Ash.Resource
alias Plug.Conn
@doc """
The register action.
See "Registration and Sign-in" above.
"""
@impl true
@spec register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
defdelegate register_action(resource, attributes), to: __MODULE__.Actions, as: :register
@doc """
The sign-in action.
See "Registration and Sign-in" above.
"""
@impl true
@spec sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
defdelegate sign_in_action(resource, attributes), to: __MODULE__.Actions, as: :sign_in
@doc """
The request plug.
Called by the router when a request which can be handled by this provider is
received.
"""
@impl true
@spec request_plug(Conn.t(), any) :: Conn.t()
defdelegate request_plug(conn, config), to: __MODULE__.Plug, as: :request
@doc """
The callback plug.
Called by the router when a user returns from the remote provider.
"""
@impl true
@spec callback_plug(Conn.t(), any) :: Conn.t()
defdelegate callback_plug(conn, config), to: __MODULE__.Plug, as: :callback
@doc false
@impl true
@spec has_register_step?(Resource.t()) :: boolean
def has_register_step?(_), do: false
@doc false
@impl true
def provides(resource) do
resource
|> __MODULE__.Info.provider_name!()
|> to_string()
end
end

View file

@ -1,76 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.Actions do
@moduledoc """
Code interface for oauth2 authentication actions.
Allows you to use the OAuth2 authentication provider without needing to mess
with around with changesets, apis, etc. These functions are delegated to from
within `AshAuthentication.OAuth2Authentication`.
"""
alias Ash.{Changeset, Query, Resource}
alias AshAuthentication.OAuth2Authentication, as: OAuth2
@doc """
Attempt to register a user based on the `user_info` and `oauth_tokens` from a
completed OAuth2 request.
"""
@spec register(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
def register(resource, attributes),
do: register(resource, attributes, OAuth2.Info.registration_enabled?(resource))
defp register(resource, attributes, true) do
action_name = OAuth2.Info.register_action_name!(resource)
api = AshAuthentication.Info.authentication_api!(resource)
action = Resource.Info.action(resource, action_name, :create)
resource
|> Changeset.for_create(action_name, attributes,
upsert?: true,
upsert_identity: action.upsert_identity
)
|> api.create()
end
defp register(resource, _attributes, false) do
provider_name = OAuth2.Info.provider_name!(resource)
{:error,
"""
Registration of new #{provider_name} users is disabled for resource `#{inspect(resource)}`.
Hint: call `AshAuthentication.OAuth2Authentication.sign_in_action/2` instead.
"""}
end
@doc """
Attempt to sign in a user based on the `user_info` and `oauth_tokens` from a
completed OAuth2 request.
"""
@spec sign_in(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
def sign_in(resource, attributes),
do: sign_in(resource, attributes, OAuth2.Info.sign_in_enabled?(resource))
defp sign_in(resource, attributes, true) do
action = OAuth2.Info.sign_in_action_name!(resource)
api = AshAuthentication.Info.authentication_api!(resource)
resource
|> Query.for_read(action, attributes)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
end
defp sign_in(resource, _attributes, false) do
provider_name = OAuth2.Info.provider_name!(resource)
{:error,
"""
Signing in #{provider_name} users is disabled for resource `#{inspect(resource)}`.
Hint: call `AshAuthentication.OAuth2Authentication.register_action/2` instead.
"""}
end
end

View file

@ -1,32 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.Html do
@moduledoc """
Renders a very basic sign-in button.
"""
require EEx
alias AshAuthentication.OAuth2Authentication, as: OAuth2
EEx.function_from_string(
:defp,
:render,
~s"""
<a href="<%= @action %>"><%= @legend %></a>
""",
[:assigns]
)
@doc false
@spec callback(module, keyword) :: String.t()
def callback(_, _), do: ""
@doc false
@spec request(module, keyword) :: String.t()
def request(resource, options) do
options
|> Map.new()
|> Map.merge(OAuth2.Info.options(resource))
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|> Map.put_new(:legend, "Sign in with #{OAuth2.provides(resource)}")
|> render()
end
end

View file

@ -1,45 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.IdentityChange do
@moduledoc """
Updates the identity resource when a user is registered.
"""
use Ash.Resource.Change
alias AshAuthentication.OAuth2Authentication, as: OAuth2
alias AshAuthentication.ProviderIdentity
alias Ash.{Changeset, Resource.Change}
import AshAuthentication.Utils, only: [is_falsy: 1]
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
identity_resource = OAuth2.Info.identity_resource!(changeset.resource)
maybe_change(changeset, identity_resource)
end
defp maybe_change(changeset, falsy) when is_falsy(falsy), do: changeset
defp maybe_change(changeset, identity_resource) do
identity_relationship = OAuth2.Info.identity_relationship_name!(changeset.resource)
provider_name = OAuth2.Info.provider_name!(changeset.resource)
changeset
|> Changeset.after_action(fn changeset, user ->
identity_resource
|> ProviderIdentity.Actions.upsert(%{
user_info: Changeset.get_argument(changeset, :user_info),
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
provider: provider_name,
user_id: user.id
})
|> case do
{:ok, _identity} ->
user
|> changeset.api.load(identity_relationship)
{:error, reason} ->
{:error, reason}
end
end)
end
end

View file

@ -1,88 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.Info do
@moduledoc """
Generated configuration functions based on a resource's DSL configuration.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.OAuth2Authentication,
sections: [:oauth2_authentication]
alias Ash.Resource
alias AshAuthentication.OAuth2Authentication, as: OAuth2
@doc """
Returns the resource configuration in a format ready for use by `Assent`.
"""
@spec resource_config(Resource.t()) :: {:ok, keyword} | {:error, any}
def resource_config(resource) do
with {:ok, auth_method} <- auth_method(resource),
{:ok, client_id} <- fetch_secret(resource, :client_id),
{:ok, client_secret} <- get_secret(resource, :client_secret),
{:ok, private_key} <- get_secret(resource, :private_key),
{:ok, jwt_algorithm} <-
AshAuthentication.Info.tokens_signing_algorithm(resource),
{:ok, authorization_params} <- authorization_params(resource),
{:ok, redirect_uri} <- fetch_secret(resource, :redirect_uri),
{:ok, site} <- fetch_secret(resource, :site),
{:ok, authorize_path} <- authorize_path(resource),
{:ok, token_path} <- token_path(resource),
{:ok, user_path} <- user_path(resource) do
config =
[
auth_method: auth_method,
client_id: client_id,
client_secret: client_secret,
private_key: private_key,
jwt_algoirthm: jwt_algorithm,
authorization_params: authorization_params,
redirect_uri: build_redirect_uri(redirect_uri, resource),
site: site,
authorize_url: append_uri_path(site, authorize_path),
token_url: append_uri_path(site, token_path),
user_url: append_uri_path(site, user_path),
http_adapter: Assent.HTTPAdapter.Mint
]
|> Enum.reject(&is_nil(elem(&1, 1)))
{:ok, config}
end
end
defp fetch_secret(resource, secret_name) do
with {:ok, {secret_module, secret_opts}} <- apply(__MODULE__, secret_name, [resource]),
{:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <-
secret_module.secret_for([:oauth2_authentication, secret_name], resource, secret_opts) do
{:ok, secret}
else
_ -> {:error, {:missing_secret, secret_name}}
end
end
defp get_secret(resource, secret_name) do
case fetch_secret(resource, secret_name) do
{:ok, secret} -> {:ok, secret}
_ -> {:ok, nil}
end
end
defp build_redirect_uri(base, resource) do
uri = URI.new!(base)
config = AshAuthentication.resource_config(resource)
path =
Path.join([
uri.path || "/",
to_string(config.subject_name),
OAuth2.provides(resource),
"callback"
])
%URI{uri | path: path} |> to_string()
end
defp append_uri_path(base, path) do
uri = URI.new!(base)
path = Path.join(uri.path || "/", path)
%URI{uri | path: path} |> to_string()
end
end

View file

@ -1,94 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.Plug do
@moduledoc """
Handlers for incoming OAuth2 HTTP requests.
"""
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
alias AshAuthentication.Errors.AuthenticationFailed
alias AshAuthentication.OAuth2Authentication, as: OAuth2
alias Assent.Strategy.OAuth2, as: Strategy
alias Plug.Conn
import Plug.Conn
require Logger
@doc """
Perform the request phase of OAuth2.
Builds a redirection URL based on the provider configuration and redirects the
user to that endpoint.
"""
@spec request(Conn.t(), any) :: Conn.t()
def request(conn, _opts) when is_map(conn.private.authenticator) do
config = conn.private.authenticator
with {:ok, provider_name} <- OAuth2.Info.provider_name(config.resource),
{:ok, resource_config} <- OAuth2.Info.resource_config(config.resource),
{:ok, %{session_params: session_params, url: url}} <-
Strategy.authorize_url(resource_config) do
conn
|> put_session(session_key(config), session_params)
|> put_resp_header("location", url)
|> send_resp(:found, "Redirecting to #{provider_name}")
else
:error ->
Logger.error(
"Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`"
)
conn
{:error, reason} ->
Logger.error(
"Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`: #{inspect(reason)}`"
)
conn
end
end
@doc """
Perform the callback phase of OAuth2.
Responds to a user being redirected back from the remote authentication
provider, and validates the passed options, ultimately registering or
signing-in a user if the authentication was successful.
"""
@spec callback(Conn.t(), any) :: Conn.t()
def callback(conn, _opts) when is_map(conn.private.authenticator) do
config = conn.private.authenticator
with {:ok, resource_config} <- OAuth2.Info.resource_config(config.resource),
session_key <- session_key(config),
session_params when is_map(session_params) <- get_session(conn, session_key),
conn <- delete_session(conn, session_key),
resource_config <- Assent.Config.put(resource_config, :session_params, session_params),
{:ok, %{user: user, token: token}} <- Strategy.callback(resource_config, conn.params),
{:ok, user} <- register_or_sign_in_user(config, %{user_info: user, oauth_tokens: token}) do
private_store(conn, {:success, user})
else
{:error, reason} -> private_store(conn, {:failure, reason})
_ -> conn
end
end
# We need to temporarily store some information about the request in the
# session so that we can verify that there hasn't been a CSRF-related attack.
defp session_key(config),
do: "#{config.subject_name}/#{config.provider.provides(config.resource)}"
defp register_or_sign_in_user(config, params) do
registration_enabled? = OAuth2.Info.registration_enabled?(config.resource)
sign_in_enabled? = OAuth2.Info.sign_in_enabled?(config.resource)
cond do
registration_enabled? ->
OAuth2.register_action(config.resource, params)
sign_in_enabled? ->
OAuth2.sign_in_action(config.resource, params)
true ->
{:error, AuthenticationFailed.exception([])}
end
end
end

View file

@ -1,72 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.SignInPreparation do
@moduledoc """
Prepare a query for sign in
Performs three main tasks:
1. Ensures that there is only one matching user record returned, otherwise
returns an authentication failed error.
2. Generates an access token if token generation is enabled.
3. Updates the user identity resource, if one is enabled.
"""
use Ash.Resource.Preparation
alias AshAuthentication.OAuth2Authentication, as: OAuth2
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, ProviderIdentity}
alias Ash.{Query, Resource.Preparation}
require Ash.Query
import AshAuthentication.Utils, only: [is_falsy: 1]
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do
query
|> Query.after_action(fn
query, [user] ->
with {:ok, user} <- maybe_update_identity(user, query) do
{:ok, [maybe_generate_token(user)]}
end
_, _ ->
{:error, AuthenticationFailed.exception(query: query)}
end)
end
defp maybe_update_identity(user, query) do
case OAuth2.Info.identity_resource(query.resource) do
{:ok, falsy} when is_falsy(falsy) ->
user
:error ->
user
{:ok, resource} ->
identity_relationship = OAuth2.Info.identity_relationship_name!(query.resource)
resource
|> ProviderIdentity.Actions.upsert(%{
user_info: Query.get_argument(query, :user_info),
oauth_tokens: Query.get_argument(query, :oauth_tokens),
provider: OAuth2.Info.provider_name!(query.resource),
user_id: user.id
})
|> case do
{:ok, _identity} ->
user
|> query.api.load(identity_relationship)
{:error, reason} ->
{:error, reason}
end
end
end
defp maybe_generate_token(user) do
if AshAuthentication.Info.tokens_enabled?(user.__struct__) do
{:ok, token, _claims} = Jwt.token_for_record(user)
%{user | __metadata__: Map.put(user.__metadata__, :token, token)}
else
user
end
end
end

View file

@ -1,205 +0,0 @@
defmodule AshAuthentication.OAuth2Authentication.Transformer do
@moduledoc """
The OAuth2Authentication Authentication transformer.
Scans the resource and checks that all the fields and actions needed are
present.
"""
use Spark.Dsl.Transformer
alias Ash.Resource
alias AshAuthentication.GenerateTokenChange
alias AshAuthentication.OAuth2Authentication, as: OAuth2
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
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_extension(dsl_state, AshAuthentication),
{:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state),
{:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, identity_resource),
{:ok, dsl_state} <-
maybe_set_action_name(dsl_state, :register_action_name, "register_with_"),
{:ok, register_action_name} <- OAuth2.Info.register_action_name(dsl_state),
registration_enabled? <- OAuth2.Info.registration_enabled?(dsl_state),
:ok <- validate_register_action(dsl_state, register_action_name, registration_enabled?),
{:ok, dsl_state} <-
maybe_set_action_name(dsl_state, :sign_in_action_name, "sign_in_with_"),
{:ok, sign_in_action_name} <- OAuth2.Info.sign_in_action_name(dsl_state),
sign_in_enabled? <- OAuth2.Info.sign_in_enabled?(dsl_state),
:ok <- validate_sign_in_action(dsl_state, sign_in_action_name, sign_in_enabled?),
:ok <- validate_only_one_action_enabled(dsl_state) do
authentication =
Transformer.get_persisted(dsl_state, :authentication)
|> Map.update(
:providers,
[AshAuthentication.OAuth2Authentication],
&[AshAuthentication.OAuth2Authentication | &1]
)
dsl_state =
dsl_state
|> Transformer.persist(:authentication, authentication)
{:ok, dsl_state}
else
{:error, reason} when is_binary(reason) ->
{:error, DslError.exception(path: [:oauth2_authentication], message: reason)}
{:error, reason} ->
{:error, reason}
:error ->
{:error,
DslError.exception(
path: [:oauth2_authentication],
message: "Configuration error while validating `oauth2_authentication`."
)}
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?(Resource.Transformers.HasDestinationField), do: true
def before?(Resource.Transformers.SetRelationshipSource), do: true
def before?(Resource.Transformers.ValidateRelationshipAttributes), do: true
def before?(_), do: false
defp validate_only_one_action_enabled(dsl_state) do
registration_enabled? = OAuth2.Info.registration_enabled?(dsl_state)
sign_in_enabled? = OAuth2.Info.sign_in_enabled?(dsl_state)
case {registration_enabled?, sign_in_enabled?} do
{true, true} ->
{:error, "Only one of `registration_enabled?` and `sign_in_enabled?` can be set."}
{false, false} ->
{:error, "One of either `registration_enabled?` and `sign_in_enabled?` must be set."}
_ ->
:ok
end
end
defp maybe_set_action_name(dsl_state, option, prefix) do
cfg = OAuth2.Info.options(dsl_state)
case Map.fetch(cfg, option) do
{:ok, _value} ->
{:ok, dsl_state}
:error ->
action_name = String.to_atom("#{prefix}#{cfg.provider_name}")
{:ok, Transformer.set_option(dsl_state, [:oauth2_authentication], option, action_name)}
end
end
defp maybe_build_identity_relationship(dsl_state, falsy) when is_falsy(falsy),
do: {:ok, dsl_state}
defp maybe_build_identity_relationship(dsl_state, identity_resource) do
with {:ok, identity_relationship} <- OAuth2.Info.identity_relationship_name(dsl_state) do
maybe_build_relationship(
dsl_state,
identity_relationship,
&build_identity_relationship(&1, identity_relationship, identity_resource)
)
end
end
defp validate_register_action(_dsl_state, _action_name, false), do: :ok
defp validate_register_action(dsl_state, action_name, true) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_has_argument(action, :user_info),
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
:ok <- validate_action_has_argument(action, :oauth_tokens),
:ok <-
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
:ok <- maybe_validate_action_has_token_change(dsl_state, action),
:ok <- validate_field_in_values(action, :upsert?, [true]),
:ok <-
validate_field_with(
action,
:upsert_identity,
&(is_atom(&1) and not is_falsy(&1)),
"Expected `upsert_identity` to be set"
),
{:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state),
:ok <- maybe_validate_action_has_identity_change(action, identity_resource) do
:ok
else
:error ->
{:error, "Unable to validate register action"}
{:error, reason} when is_binary(reason) ->
{:error, "`#{inspect(action_name)}` action: #{reason}"}
{:error, reason} ->
{:error, reason}
end
end
defp validate_sign_in_action(_dsl_state, _action_name, false), do: :ok
defp validate_sign_in_action(dsl_state, action_name, true) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_has_argument(action, :user_info),
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
:ok <- validate_action_has_argument(action, :oauth_tokens),
:ok <-
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
:ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do
:ok
else
:error -> {:error, "Unable to validate sign in action"}
{:error, reason} -> {:error, reason}
end
end
defp maybe_validate_action_has_token_change(dsl_state, action) do
if AshAuthentication.Info.tokens_enabled?(dsl_state) do
validate_action_has_change(action, GenerateTokenChange)
else
:ok
end
end
defp build_identity_relationship(dsl_state, name, destination) do
with {:ok, dest_attr} <- OAuth2.Info.identity_relationship_user_id_attribute(dsl_state) do
Transformer.build_entity(Resource.Dsl, [:relationships], :has_many,
name: name,
destination: destination,
destination_attribute: dest_attr
)
end
end
defp maybe_validate_action_has_identity_change(_action, falsy) when is_falsy(falsy), do: :ok
defp maybe_validate_action_has_identity_change(action, _identity_resource),
do: validate_action_has_change(action, OAuth2.IdentityChange)
end

View file

@ -1,201 +0,0 @@
defmodule AshAuthentication.PasswordAuthentication do
@dsl [
%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 user'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.
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
```elixir
defmodule MyApp.Accounts.User 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
```
## Endpoints
This provider routes requests to both the `request` and `callback` endpoints
to the same handler, so either can be used. Requests are differentiated by
the presence of an `action` parameter in the request body.
### Examples
When attempting to register a new user
```
%{"user" => %{
"action" => "register",
"email" => "marty@mcfly.me",
"password" => "back to 1985",
"password_confirmation" => "back to 1985"
# any additional user fields you wish to accept on creation.
}}
```
When attempting to sign-in a user
```
%{"user" => %{
"action" => "sign_in",
"email" => "marty@mcfly.me",
"password" => "back to 1985"
}}
```
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
"""
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.PasswordAuthentication.Transformer]
use AshAuthentication.Provider
alias Ash.Resource
alias AshAuthentication.PasswordAuthentication
@doc """
Attempt to sign in an user 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(Resource.t(), map) :: {:ok, struct} | {:error, term}
defdelegate sign_in_action(resource, attributes),
to: PasswordAuthentication.Actions,
as: :sign_in
@doc """
Attempt to register an user 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(Resource.t(), map) :: {:ok, struct} | {:error, term}
defdelegate register_action(resource, attributes),
to: PasswordAuthentication.Actions,
as: :register
@doc """
Handle the callback phase.
Handles both sign-in and registration actions via the same endpoint.
"""
@impl true
defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle
@doc """
Handle the request phase.
Handles both sign-in and registration actions via the same endpoint.
"""
@impl true
defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle
@doc false
@impl true
@spec has_register_step?(Resource.t()) :: boolean
def has_register_step?(_resource), do: true
end

View file

@ -1,57 +0,0 @@
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. These functions are delegated to from
within `AshAuthentication.PasswordAuthentication`.
"""
alias Ash.{Changeset, Query}
alias AshAuthentication.PasswordAuthentication
@doc """
Attempt to sign in an user 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.password_authentication_sign_in_action_name(resource)
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
resource
|> Query.for_read(action, attributes)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
{:ok, []} -> {:error, "Invalid username or password"}
{:error, reason} -> {:error, reason}
end
end
@doc """
Attempt to register an user 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.password_authentication_register_action_name(resource)
{:ok, api} = AshAuthentication.Info.authentication_api(resource)
resource
|> Changeset.for_create(action, attributes)
|> api.create()
end
end

View file

@ -1,32 +0,0 @@
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_authentication_password_field(changeset.resource)
{:ok, hash_field} = Info.password_authentication_hashed_password_field(changeset.resource)
{:ok, hasher} = Info.password_authentication_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

@ -1,105 +0,0 @@
defmodule AshAuthentication.PasswordAuthentication.Html do
@moduledoc """
Renders a very basic form for using password authentication.
These are mainly used for testing, and you should instead write your own or
use the widgets in `ash_authentication_phoenix`.
"""
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="<%= @identity_field %>" />
<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 callback(module, options) :: String.t()
def callback(resource, options) do
options =
options
|> Keyword.put_new(:legend, "Sign in")
resource
|> build_assigns(options)
|> render_sign_in()
end
@doc """
Render a basic HTML registration form.
"""
@spec request(module, options) :: String.t()
def request(resource, options) do
options =
options
|> Keyword.put_new(:legend, "Register")
resource
|> build_assigns(options)
|> render_register()
end
defp build_assigns(resource, options) do
@defaults
|> Keyword.merge(options)
|> Map.new()
|> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource))
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
end
end

View file

@ -1,10 +0,0 @@
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],
prefix?: true
end

View file

@ -1,32 +0,0 @@
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.
"""
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
def validate(changeset, _) do
with true <- Info.password_authentication_confirmation_required?(changeset.resource),
{:ok, password_field} <- Info.password_authentication_password_field(changeset.resource),
{:ok, confirm_field} <-
Info.password_authentication_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

@ -1,46 +0,0 @@
defmodule AshAuthentication.PasswordAuthentication.Plug do
@moduledoc """
Handlers for incoming 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 user in
a single flow. This doesn't really work that well with
`PasswordAuthentication` which has seperate "registration" and "sign-in"
actions.
We 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 callback phase.
Handles both sign-in and registration actions via the same endpoint.
"""
@spec handle(Conn.t(), any) :: Conn.t()
def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do
params
|> Map.get(to_string(config.subject_name), %{})
|> do_action(config.resource)
|> case do
{:ok, user} when is_struct(user, config.resource) ->
private_store(conn, {:success, user})
{:error, changeset} ->
private_store(conn, {:failure, changeset})
end
end
def handle(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)
defp do_action(_attrs, _resource), do: {:error, "No action provided"}
end

View file

@ -1,215 +0,0 @@
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 `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.{
HashPasswordChange,
Info,
PasswordConfirmationValidation,
SignInPreparation
}
alias Ash.{Resource, Type}
alias AshAuthentication.GenerateTokenChange
alias Spark.Dsl.Transformer
import AshAuthentication.PasswordAuthentication.UserValidations
import AshAuthentication.Utils
import AshAuthentication.Validations
@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_extension(dsl_state, AshAuthentication),
{:ok, dsl_state} <- validate_identity_field(dsl_state),
{:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
{:ok, register_action_name} <-
Info.password_authentication_register_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
register_action_name,
&build_register_action(&1, register_action_name)
),
{:ok, dsl_state} <- validate_register_action(dsl_state),
{:ok, sign_in_action_name} <-
Info.password_authentication_sign_in_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
sign_in_action_name,
&build_sign_in_action(&1, sign_in_action_name)
),
{: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, action_name) do
with {:ok, hashed_password_field} <-
Info.password_authentication_hashed_password_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, confirm_field} <-
Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <- Info.password_authentication_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: action_name,
arguments: arguments,
changes: changes,
allow_nil_input: [hashed_password_field]
)
end
end
defp build_sign_in_action(dsl_state, action_name) do
with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_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: action_name,
arguments: arguments,
preparations: preparations,
get?: true
)
end
end
end

View file

@ -1,196 +0,0 @@
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.{GenerateTokenChange, HashProvider}
alias AshAuthentication.PasswordAuthentication.{
HashPasswordChange,
Info,
PasswordConfirmationValidation,
SignInPreparation
}
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@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.password_authentication_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.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, action_name} <- Info.password_authentication_sign_in_action_name(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
: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_authentication_password_field(dsl_state),
{:ok, password_confirmation_field} <-
Info.password_authentication_password_confirmation_field(dsl_state),
{:ok, hashed_password_field} <-
Info.password_authentication_hashed_password_field(dsl_state),
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state),
{:ok, action_name} <- Info.password_authentication_register_action_name(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
: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.password_authentication_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, :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

@ -1,212 +0,0 @@
defmodule AshAuthentication.PasswordReset do
@default_lifetime_days 3
@dsl [
%Spark.Dsl.Section{
name: :password_reset,
describe: "Configure password reset behaviour",
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the reset token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
request_password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which generates a password reset token.
""",
default: :request_password_reset
],
password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which actually resets the user's password.
""",
default: :reset_password
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 2}},
doc: """
How to send the password reset instructions to the user.
Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application.
Accepts a module, module and opts, or a function that takes a record, reset token and options.
See `AshAuthentication.Sender` for more information.
""",
required: true
]
]
}
]
@moduledoc """
Allow users to reset their passwords.
This extension provides a mechanism to allow users to reset their password as
in your typical "forgotten password" flow.
This requires the `AshAuthentication.PasswordAuthentication` extension to be
present, in order to be able to update the password.
## Senders
You can set the DSL's `sender` key to be either a three-arity anonymous
function or a module which implements the `AshAuthentication.Sender`
behaviour. This callback can be used to send password reset instructions to
the user via the system of your choice. See `AshAuthentication.Sender` for
more information.
## Usage
```elixir
defmodule MyApp.Accounts.Users do
use Ash.Resource,
extensions: [
AshAuthentication.PasswordAuthentication,
AshAuthentication.PasswordReset
]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
password_reset do
token_lifetime 24
sender MyApp.ResetRequestSender
end
end
```
## Endpoints
* `request` - send the identity field nested below the subject name (eg
`%{"user" => %{"email" => "marty@mcfly.me"}}`). If the resource supports
password resets then the success callback will be called with a `nil` user
and token regardless of whether the user could be found. If the user is
found then the `sender` will be called.
* `callback` - attempt to perform a password reset. Should be called with the
reset token, password and password confirmation if confirmation is enabled,
nested below the subject name (eg `%{"user" => %{"reset_token" => "abc123",
"password" => "back to 1985", "password_confirmation" => "back to 1975"}}`).
If the password was successfully changed then the relevant user will be
returned to the `success` callback.
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
"""
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.PasswordReset.Transformer]
use AshAuthentication.Provider
alias Ash.{Changeset, Query, Resource}
alias AshAuthentication.{Jwt, PasswordReset}
@doc """
Request a password reset for a user.
If the record supports password resets then the reset token will be generated and sent.
## Example
iex> request_password_reset(MyApp.Accounts.User, %{"email" => "marty@mcfly.me"})
:ok
"""
@spec request_password_reset(Resource.t(), params) :: :ok | {:error, any}
when params: %{required(String.t()) => String.t()}
def request_password_reset(resource, params) do
with true <- enabled?(resource),
{:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource),
{:ok, api} <- AshAuthentication.Info.authentication_api(resource),
query <- Query.for_read(resource, action, params),
{:ok, _} <- api.read(query) do
:ok
else
{:error, reason} -> {:error, reason}
_ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
end
end
@doc """
Reset a user's password.
Given a reset token, password and _maybe_ password confirmation, validate and
change the user's password.
## Example
iex> reset_password(MyApp.Accounts.User, params)
{:ok, %MyApp.Accounts.User{}}
"""
@spec reset_password(Resource.t(), params) :: {:ok, Resource.record()} | {:error, Changeset.t()}
when params: %{required(String.t()) => String.t()}
def reset_password(resource, params) do
with true <- enabled?(resource),
{:ok, token} <- Map.fetch(params, "reset_token"),
{:ok, %{"sub" => subject}, config} <- Jwt.verify(token, resource),
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
{:ok, action} <- PasswordReset.Info.password_reset_action_name(config.resource),
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
user
|> Changeset.for_update(action, params)
|> api.update()
else
false -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
:error -> {:error, "Invalid reset token"}
{:error, reason} -> {:error, reason}
end
end
@doc """
Generate a reset token for a user.
"""
@spec reset_token_for(Resource.record()) :: {:ok, String.t()} | :error
def reset_token_for(user) do
resource = user.__struct__
with true <- enabled?(resource),
{:ok, lifetime} <- PasswordReset.Info.token_lifetime(resource),
{:ok, action} <- PasswordReset.Info.password_reset_action_name(resource),
{:ok, token, _claims} <-
Jwt.token_for_record(user, %{"act" => action}, token_lifetime: lifetime) do
{:ok, token}
else
_ -> :error
end
end
@doc """
Handle the request phase.
Handles a HTTP request for a password reset.
"""
@impl true
defdelegate request_plug(conn, any), to: PasswordReset.Plug, as: :request
@doc """
Handle the callback phase.
Handles a HTTP password change request.
"""
@impl true
defdelegate callback_plug(conn, any), to: PasswordReset.Plug, as: :callback
end

View file

@ -1,94 +0,0 @@
defmodule AshAuthentication.PasswordReset.Html do
@moduledoc """
Renders a very basic form for password resetting.
These are mainly used for testing, and you should instead write your own or
use the widgets in `ash_authentication_phoenix`.
"""
require EEx
alias AshAuthentication.{PasswordAuthentication, PasswordReset}
EEx.function_from_string(
:defp,
:render_request,
~s"""
<form method="<%= @method %>" action="<%= @action %>">
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="<%= @identity_field %>" />
<br />
<input type="submit" value="Request password reset" />
</fieldset>
</form>
""",
[:assigns]
)
EEx.function_from_string(
:defp,
:render_reset,
~s"""
<form method="<%= @method %>" action="<%= @action %>">
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="token" name="<%= @subject_name %>[reset_token]" placeholder="Reset token" />
<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="Reset password" />
</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 reset request.
"""
@spec request(module, options) :: String.t()
def request(resource, options) do
resource
|> build_assigns(Keyword.put(options, :legend, "Request password reset"))
|> render_request()
end
@doc """
Render a reset form
"""
@spec callback(module, options) :: String.t()
def callback(resource, options) do
resource
|> build_assigns(Keyword.put(options, :legend, "Reset password"))
|> render_reset()
end
defp build_assigns(resource, options) do
@defaults
|> Keyword.merge(options)
|> Map.new()
|> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource))
|> Map.merge(PasswordReset.Info.options(resource))
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
end
end

View file

@ -1,9 +0,0 @@
defmodule AshAuthentication.PasswordReset.Info do
@moduledoc """
Generated configuration functions based on a resource's DSL configuration.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.PasswordReset,
sections: [:password_reset]
end

View file

@ -1,45 +0,0 @@
defmodule AshAuthentication.PasswordReset.Plug do
@moduledoc """
Handlers for incoming HTTP requests.
"""
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
alias AshAuthentication.PasswordReset
alias Plug.Conn
@doc """
Handle an inbound password reset request.
"""
@spec request(Conn.t(), any) :: Conn.t()
def request(%{params: params, private: %{authenticator: config}} = conn, _opts) do
params =
params
|> Map.get(to_string(config.subject_name), %{})
case PasswordReset.request_password_reset(config.resource, params) do
:ok ->
private_store(conn, {:success, nil})
{:error, reason} ->
private_store(conn, {:failure, reason})
end
end
@doc """
Handle an inbound password reset.
"""
@spec callback(Conn.t(), any) :: Conn.t()
def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do
params =
params
|> Map.get(to_string(config.subject_name), %{})
case PasswordReset.reset_password(config.resource, params) do
{:ok, user} when is_struct(user, config.resource) ->
private_store(conn, {:success, user})
{:error, reason} ->
private_store(conn, {:failure, reason})
end
end
end

View file

@ -1,45 +0,0 @@
defmodule AshAuthentication.PasswordReset.RequestPasswordResetPreparation do
@moduledoc """
Prepare a query for a password reset request.
This preparation performs three jobs, one before the query executes and two
after.
Firstly, it constraints the query to match the identity field passed to the
action.
Secondly, if there is a user returned by the query, then generate a reset
token and publish a notification. Always returns an empty result.
"""
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.{PasswordAuthentication, PasswordReset}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do
{:ok, identity_field} =
PasswordAuthentication.Info.password_authentication_identity_field(query.resource)
{:ok, {sender, send_opts}} = PasswordReset.Info.sender(query.resource)
identity = Query.get_argument(query, identity_field)
query
|> Query.filter(ref(^identity_field) == ^identity)
|> Query.after_action(fn
_query, [user] ->
case PasswordReset.reset_token_for(user) do
{:ok, token} -> sender.send(user, token, send_opts)
_ -> nil
end
{:ok, []}
_, _ ->
{:ok, []}
end)
end
end

View file

@ -1,217 +0,0 @@
defmodule AshAuthentication.PasswordReset.Transformer do
@moduledoc """
The PasswordReset transformer.
Scans the resource and checks that all the fields and actions needed are
present.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.PasswordReset.{
Info,
RequestPasswordResetPreparation,
ResetTokenValidation
}
alias Ash.{Resource, Type}
alias AshAuthentication.PasswordAuthentication, as: PA
alias AshAuthentication.{GenerateTokenChange, Sender}
alias Spark.Dsl.Transformer
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
@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_extension(dsl_state, AshAuthentication),
:ok <- validate_extension(dsl_state, PA),
:ok <- validate_token_generation_enabled(dsl_state),
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
:ok <- validate_behaviour(sender, Sender),
{:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
request_action_name,
&build_request_action(&1, request_action_name)
),
:ok <- validate_request_action(dsl_state, request_action_name),
{:ok, change_action_name} <- Info.password_reset_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
change_action_name,
&build_change_action(&1, change_action_name)
),
:ok <- validate_change_action(dsl_state, change_action_name) do
authentication =
Transformer.get_persisted(dsl_state, :authentication)
|> Map.update(
:providers,
[AshAuthentication.PasswordReset],
&[AshAuthentication.PasswordReset | &1]
)
dsl_state =
dsl_state
|> Transformer.persist(:authentication, authentication)
{:ok, dsl_state}
else
:error -> {:error, "Configuration error"}
{:error, reason} -> {:error, reason}
end
end
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(PA.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_request_action(dsl_state, action_name) do
with {:ok, identity_field} <- PA.Info.password_authentication_identity_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
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: RequestPasswordResetPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: action_name,
arguments: arguments,
preparations: preparations
)
end
end
defp build_change_action(dsl_state, action_name) do
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
{:ok, confirm_field} <-
PA.Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <-
PA.Info.password_authentication_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, :update],
:argument,
name: :reset_token,
type: Type.String,
sensitive?: true
),
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, password_field)
)
]
|> maybe_append(
confirmation_required?,
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, confirm_field)
)
)
changes =
[
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: ResetTokenValidation
)
]
|> maybe_append(
confirmation_required?,
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: PA.PasswordConfirmationValidation
)
)
|> Enum.concat([
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: PA.HashPasswordChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: GenerateTokenChange
)
])
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: action_name,
arguments: arguments,
changes: changes,
accept: []
)
end
end
defp validate_request_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
{:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state),
:ok <- PA.UserValidations.validate_identity_argument(dsl_state, action, identity_field) do
validate_action_has_preparation(action, RequestPasswordResetPreparation)
end
end
defp validate_change_action(dsl_state, action_name) do
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
{:ok, password_confirmation_field} <-
PA.Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <-
PA.Info.password_authentication_confirmation_required?(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_has_validation(action, ResetTokenValidation),
:ok <- validate_action_has_change(action, PA.HashPasswordChange),
:ok <- PA.UserValidations.validate_password_argument(action, password_field),
:ok <-
PA.UserValidations.validate_password_confirmation_argument(
action,
password_confirmation_field,
confirmation_required?
),
:ok <-
PA.UserValidations.validate_action_has_validation(
action,
PA.PasswordConfirmationValidation,
confirmation_required?
) do
validate_action_has_change(action, GenerateTokenChange)
end
end
end

View file

@ -8,13 +8,13 @@ defmodule AshAuthentication.Plug do
defmodule MyAppWeb.AuthPlug do
use AshAuthentication.Plug, otp_app: :my_app
def handle_success(conn, user, _token) do
def handle_success(conn, _activity, user, _token) do
conn
|> store_in_session(user)
|> send_resp(200, "Welcome back #{user.name}")
end
def handle_failure(conn, reason) do
def handle_failure(conn, _activity, reason) do
conn
|> send_resp(401, "Better luck next time")
end
@ -69,21 +69,17 @@ defmodule AshAuthentication.Plug do
do useful things like session and query param fetching.
"""
alias Ash.{Changeset, Error, Resource}
alias Ash.Resource
alias AshAuthentication.Plug.{Defaults, Helpers, Macros}
alias Plug.Conn
require Macros
@type authenticator_config :: %{
api: module,
provider: module,
resource: module,
subject: atom
}
@type activity :: {atom, atom}
@type token :: String.t()
@doc """
When authentication has been succesful, this callback will be called with the
conn, the authenticated resource and a token.
conn, the successful activity, the authenticated resource and a token.
This allows you to choose what action to take as appropriate for your
application.
@ -92,19 +88,21 @@ defmodule AshAuthentication.Plug do
"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()
@callback handle_success(Conn.t(), activity, Resource.record() | nil, token | nil) :: 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.
Note that this includes not just authentication failures but potentially
route-not-found errors also.
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() | Error.t()) :: Conn.t()
@callback handle_failure(Conn.t(), activity, any) :: Conn.t()
@doc false
@spec __using__(keyword) :: Macro.t()
defmacro __using__(opts) do
otp_app =
opts
@ -135,12 +133,12 @@ defmodule AshAuthentication.Plug do
Macros.define_revoke_bearer_tokens(unquote(otp_app))
@impl true
defdelegate handle_success(conn, user, token), to: Defaults
defdelegate handle_success(conn, activity, user, token), to: Defaults
@impl true
defdelegate handle_failure(conn, error), to: Defaults
defdelegate handle_failure(conn, activity, error), to: Defaults
defoverridable handle_success: 3, handle_failure: 2
defoverridable handle_success: 4, handle_failure: 3
@impl true
defdelegate init(opts), to: Router

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.Plug.Defaults do
`handle_failure/2` used in generated authentication plugs.
"""
alias Ash.{Changeset, Error, Resource}
alias Ash.Resource
alias Plug.Conn
import AshAuthentication.Plug.Helpers
import Plug.Conn
@ -15,9 +15,9 @@ defmodule AshAuthentication.Plug.Defaults do
Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a
basic 200 response.
"""
@spec handle_success(Conn.t(), Resource.record(), token :: String.t()) ::
@spec handle_success(Conn.t(), {atom, atom}, Resource.record() | nil, String.t() | nil) ::
Conn.t()
def handle_success(conn, user, _token) do
def handle_success(conn, _activity, user, _token) do
conn
|> store_in_session(user)
|> send_resp(200, "Access granted")
@ -28,8 +28,8 @@ defmodule AshAuthentication.Plug.Defaults do
Sends a very basic 401 response.
"""
@spec handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t()
def handle_failure(conn, _) do
@spec handle_failure(Conn.t(), {atom, atom}, any) :: Conn.t()
def handle_failure(conn, _, _) do
conn
|> send_resp(401, "Access denied")
end

View file

@ -4,9 +4,13 @@ defmodule AshAuthentication.Plug.Dispatcher do
"""
@behaviour Plug
alias AshAuthentication.Strategy
alias Plug.Conn
import AshAuthentication.Plug.Helpers, only: [get_authentication_result: 1]
@type config :: {:request | :callback, [AshAuthentication.Plug.authenticator_config()], module}
@type config :: {atom, Strategy.t(), module} | module
@unsent ~w[unset set set_chunked set_file]a
@doc false
@impl true
@ -14,57 +18,44 @@ defmodule AshAuthentication.Plug.Dispatcher do
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.
Send the request to the correct strategy and then return the result.
"""
@impl true
@spec call(Conn.t(), config | any) :: Conn.t()
def call(conn, {phase, routes, return_to}) do
conn
|> dispatch(phase, routes)
|> return(return_to)
end
def call(conn, {phase, strategy, return_to}) do
activity = {strategy.name, phase}
defp dispatch(
%{params: %{"subject_name" => subject_name, "provider" => provider}} = conn,
phase,
routes
) do
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
_ ->
strategy
|> Strategy.plug(phase, conn)
|> get_authentication_result()
|> case do
{conn, _} when conn.state not in @unsent ->
conn
{conn, :ok} ->
return_to.handle_success(conn, activity, nil, nil)
{conn, {:ok, user}} when is_binary(user.__metadata__.token) ->
return_to.handle_success(conn, activity, user, user.__metadata__.token)
{conn, {:ok, user}} ->
return_to.handle_success(conn, activity, user, nil)
{conn, :error} ->
return_to.handle_failure(conn, activity, nil)
{conn, {:error, reason}} ->
return_to.handle_failure(conn, activity, reason)
conn when conn.state not in @unsent ->
conn
conn ->
return_to.handle_failure(conn, activity, :no_authentication_result)
end
end
defp dispatch(conn, _phase, _routes), do: conn
defp return(%{state: :sent} = conn, _return_to), do: conn
defp return(
%{
private: %{
authentication_result: {:success, user},
authenticator: %{resource: resource}
}
} = conn,
return_to
)
when is_struct(user, resource),
do: return_to.handle_success(conn, user, Map.get(user.__metadata__, :token))
defp return(%{private: %{authentication_result: {:success, nil}}} = conn, return_to),
do: return_to.handle_success(conn, nil, nil)
defp return(%{private: %{authentication_result: {:failure, reason}}} = conn, return_to),
do: return_to.handle_failure(conn, reason)
defp return(conn, return_to), do: return_to.handle_failure(conn, nil)
def call(conn, return_to) do
return_to.handle_failure(conn, {nil, nil}, :not_found)
end
end

View file

@ -13,8 +13,8 @@ defmodule AshAuthentication.Plug.Helpers do
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
def store_in_session(conn, user) when is_struct(user) do
subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__)
subject = AshAuthentication.resource_to_subject(user)
subject_name = Info.authentication_subject_name!(user.__struct__)
subject = AshAuthentication.user_to_subject(user)
Conn.put_session(conn, subject_name, subject)
end
@ -26,19 +26,20 @@ defmodule AshAuthentication.Plug.Helpers do
"""
@spec load_subjects([AshAuthentication.subject()], module) :: map
def load_subjects(subjects, otp_app) when is_list(subjects) do
configurations =
resources =
otp_app
|> AshAuthentication.authenticated_resources()
|> Stream.map(&{to_string(&1.subject_name), &1})
|> Stream.map(&{to_string(Info.authentication_subject_name!(&1)), &1})
|> Map.new()
subjects
|> Enum.reduce(%{}, fn subject, result ->
subject = URI.parse(subject)
with {:ok, config} <- Map.fetch(configurations, subject.path),
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
current_subject_name = current_subject_name(config.subject_name)
with {:ok, resource} <- Map.fetch(resources, subject.path),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
{:ok, subject_name} <- Info.authentication_subject_name(resource) do
current_subject_name = current_subject_name(subject_name)
Map.put(result, current_subject_name, user)
else
@ -60,11 +61,12 @@ defmodule AshAuthentication.Plug.Helpers do
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)
|> Stream.map(&{&1, Info.authentication_options(&1)})
|> Enum.reduce(conn, fn {resource, options}, conn ->
current_subject_name = current_subject_name(options.subject_name)
with subject when is_binary(subject) <- Conn.get_session(conn, config.subject_name),
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
Conn.assign(conn, current_subject_name, user)
else
_ ->
@ -89,9 +91,10 @@ defmodule AshAuthentication.Plug.Helpers do
|> 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, user} <- AshAuthentication.subject_to_resource(subject, config),
current_subject_name <- current_subject_name(config.subject_name) do
with {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, otp_app),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
{:ok, subject_name} <- Info.authentication_subject_name(resource),
current_subject_name <- current_subject_name(subject_name) do
conn
|> Conn.assign(current_subject_name, user)
else
@ -112,8 +115,8 @@ defmodule AshAuthentication.Plug.Helpers do
|> 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),
with {:ok, resource} <- Jwt.token_to_resource(token, otp_app),
{:ok, revocation_resource} <- Info.authentication_tokens_revocation_resource(resource),
:ok <- TokenRevocation.revoke(revocation_resource, token) do
conn
else
@ -170,17 +173,28 @@ defmodule AshAuthentication.Plug.Helpers do
This is used by authentication plug handlers to store their result for passing
back to the dispatcher.
"""
@spec private_store(Conn.t(), {:success, nil | Resource.record()} | {:failure, any}) :: Conn.t()
@spec store_authentication_result(
Conn.t(),
:ok | {:ok, Resource.record()} | :error | {:error, any}
) ::
Conn.t()
def private_store(conn, {:success, nil}),
do: Conn.put_private(conn, :authentication_result, {:success, nil})
def store_authentication_result(conn, :ok),
do: Conn.put_private(conn, :authentication_result, {:ok, nil})
def private_store(conn, {:success, record})
when is_struct(record, conn.private.authenticator.resource),
do: Conn.put_private(conn, :authentication_result, {:success, record})
def store_authentication_result(conn, {:ok, record}),
do: Conn.put_private(conn, :authentication_result, {:ok, record})
def private_store(conn, {:failure, reason}),
do: Conn.put_private(conn, :authentication_result, {:failure, reason})
def store_authentication_result(conn, :error),
do: Conn.put_private(conn, :authentication_result, :error)
def store_authentication_result(conn, {:error, reason}),
do: Conn.put_private(conn, :authentication_result, {:error, reason})
def get_authentication_result(%{private: %{authentication_result: result}} = conn),
do: {conn, result}
def get_authentication_result(conn), do: conn
# Dyanamically generated atoms are generally frowned upon, but in this case
# the `subject_name` is a statically configured atom, so should be fine.

View file

@ -1,6 +1,6 @@
defmodule AshAuthentication.Plug.Macros do
@moduledoc """
Generators used within `AshAuthentication.Plug.__using_/1`.
Generators used within `use AshAuthentication.Plug`.
"""
alias Ash.Api

View file

@ -6,6 +6,8 @@ defmodule AshAuthentication.Plug.Router do
Used internally by `AshAuthentication.Plug`.
"""
alias AshAuthentication.{Info, Strategy}
@doc false
@spec __using__(keyword) :: Macro.t()
defmacro __using__(opts) do
@ -19,47 +21,30 @@ defmodule AshAuthentication.Plug.Router do
|> Keyword.fetch!(:return_to)
|> Macro.expand_once(__CALLER__)
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.resource)}, config}
end)
end)
|> Map.new()
|> Macro.escape()
quote do
require Ash.Api.Info
use Plug.Router
plug(:match)
plug(:dispatch)
match("/:subject_name/:provider",
to: AshAuthentication.Plug.Dispatcher,
init_opts: [{:request, unquote(routes), unquote(return_to)}]
)
routes =
unquote(otp_app)
|> Application.compile_env(:ash_apis, [])
|> Stream.flat_map(&Ash.Api.Info.depend_on_resources(&1))
|> Stream.filter(&(AshAuthentication in Spark.extensions(&1)))
|> Stream.flat_map(&Info.authentication_strategies/1)
|> Stream.flat_map(fn strategy ->
strategy
|> Strategy.routes()
|> Stream.map(fn {path, phase} -> {path, {phase, strategy, unquote(return_to)}} end)
end)
|> Map.new()
match("/:subject_name/:provider/callback",
to: AshAuthentication.Plug.Dispatcher,
init_opts: [{:callback, unquote(routes), unquote(return_to)}]
)
for {path, config} <- routes do
match(path, to: AshAuthentication.Plug.Dispatcher, init_opts: [config])
end
match(_,
to: AshAuthentication.Plug.Dispatcher,
init_opts: [{:noop, [], unquote(return_to)}]
)
match(_, to: AshAuthentication.Plug.Dispatcher, init_opts: [unquote(return_to)])
end
end
end

View file

@ -1,128 +0,0 @@
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(Resource.t()) :: 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(), any) :: Conn.t()
@doc """
A function plug which can handle the request phase.
"""
@callback request_plug(Conn.t(), any) :: Conn.t()
@doc """
Is this extension enabled for this resource?
"""
@callback enabled?(Resource.t()) :: boolean
defmacro __using__(_) do
quote do
@behaviour AshAuthentication.Provider
@doc """
The name of the provider to be used in routes.
The default implementation derives it from the module name removing any
"Authentication" suffix.
Overridable.
"""
@impl true
@spec provides(Resource.t()) :: String.t()
def provides(_resource) do
__MODULE__
|> Module.split()
|> List.last()
|> String.trim_trailing("Authentication")
|> Macro.underscore()
end
@doc """
Handle a request for this extension to sign in a user.
Defaults to returning an error. Overridable.
"""
@impl true
def sign_in_action(_resource, _attributes),
do: {:error, "Sign in not supported by `#{inspect(__MODULE__)}`"}
@doc """
Handle a request for this extension to register a user.
Defaults to returning an error. Overridable.
"""
@impl true
def register_action(_resource, _attributes),
do: {:error, "Registration not supported by `#{inspect(__MODULE__)}`"}
@doc """
Handle an inbound request to the `request` path.
Defaults to returning the `conn` unchanged. Overridable.
"""
@impl true
def request_plug(conn, _config), do: conn
@doc """
Handle an inbound request to the `callback` path.
Defaults to returning the `conn` unchanged. Overridable.
"""
@impl true
def callback_plug(conn, _config), do: conn
@doc """
Does this extension require a separate register step?
Defaults to `false`. Overridable.
"""
@impl true
def has_register_step?(_resource), do: false
@doc """
Is `resource` supported by this provider?
Defaults to `false`. Overridable.
"""
@impl true
@spec enabled?(Resource.t()) :: boolean
def enabled?(resource), do: __MODULE__ in Spark.extensions(resource)
defoverridable provides: 1,
sign_in_action: 2,
register_action: 2,
request_plug: 2,
callback_plug: 2,
has_register_step?: 1,
enabled?: 1
end
end
end

View file

@ -1,10 +0,0 @@
defmodule AshAuthentication.ProviderIdentity.Info do
@moduledoc """
Generated configuration functions based on a resource's token DSL
configuration.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.ProviderIdentity,
sections: [:provider_identity]
end

View file

@ -10,16 +10,22 @@ defmodule AshAuthentication.Secret do
defmodule MyApp.GetSecret do
use AshAuthentication.Secret
def secret_for([:oauth2_authentication, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id)
def secret_for([:oauth2_authentication, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret)
def secret_for([:authentication, :strategies, :oauth2, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id)
def secret_for([:authentication, :strategies, :oauth2, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret)
end
defmodule MyApp.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication]
oauth2_authentication do
client_id MyApp.GetSecret
client_secret MyApp.GetSecret
authentication do
api MyApp.Accounts
strategies do
oauth2 do
client_id MyApp.GetSecret
client_secret MyApp.GetSecret
end
end
end
end
```
@ -28,11 +34,17 @@ defmodule AshAuthentication.Secret do
```elixir
defmodule MyApp.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication]
use Ash.Resource, extensions: [AshAuthentication]
oauth2_authentication do
client_id fn _secret, _resource, _opts ->
Application.fetch_env(:my_app, :oauth_client_id)
authentication do
api MyApp.Accounts
strategies do
oauth2 do
client_id fn _secret, _resource, _opts ->
Application.fetch_env(:my_app, :oauth_client_id)
end
end
end
end
end
@ -43,8 +55,7 @@ defmodule AshAuthentication.Secret do
Because you may wish to reuse this module for a number of different providers
and resources, the first argument passed to the callback is the "secret name",
it contains the "path" to the option being set. The path is made up of a list
containing the DSL section name (`oauth2_authentication` etc) as an atom and
the property name as an atom.
containing the DSL path to the secret.
"""
alias Ash.Resource

View file

@ -19,7 +19,7 @@ defmodule AshAuthentication.SecretFunction do
fun.(secret_name, resource, opts)
{{m, f, a}, _opts} when is_atom(m) and is_atom(f) and is_list(a) ->
apply(m, f, [secret_name, resource, a])
apply(m, f, [secret_name, resource | a])
{nil, opts} ->
raise "Invalid options given to `secret_for/3` callback: `#{inspect(opts)}`."

View file

@ -18,7 +18,6 @@ defmodule AshAuthentication.Sender do
defmodule MyApp.PasswordResetSender do
use AshAuthentication.PasswordReset.Sender
import Swoosh.Email
alias MyAppWeb.Router.Helpers, as: Routes
def send(user, reset_token, _opts) do
new()
@ -33,7 +32,7 @@ defmodule AshAuthentication.Sender do
Someone (maybe you) has requested a password reset for your account.
If you did not initiate this request then please ignore this email.
</p>
<a href="#{Routes.auth_url(MyAppWeb.Endpoint, :reset_password, token: reset_token)}">
<a href="#{"https://example.com/user/password/reset?#{URI.encode_query(reset_token: reset_token)}}">
Click here to reset
</a>
")
@ -42,24 +41,39 @@ defmodule AshAuthentication.Sender do
end
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
use Ash.Resource, extensions: [AshAuthentication]
password_reset do
sender MyApp.PasswordResetSender
authentication do
api MyApp.Accounts
strategies do
password :password do
resettable do
sender MyApp.PasswordResetSender
end
end
end
end
end
```
You can also implment it directly as a function:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
use Ash.Resource, extensions: [AshAuthentication]
password_reset do
sender fn user, token, _opt ->
MyApp.Mailer.send_password_reset_email(user, token)
authentication do
api MyApp.Accounts
strategies do
password :password do
resettable do
sender fn user, token ->
MyApp.Mailer.send_password_reset_email(user, token)
end
end
end
end
end
end

View file

@ -0,0 +1,172 @@
defmodule AshAuthentication.Strategy.Confirmation do
@default_lifetime_days 3
@moduledoc """
Strategy for authenticating sensitive changes.
Sometimes when creating a new user, or changing a sensitive attribute (such as
their email address) you may want to for the user to confirm by way of sending
them a confirmation token to prove that it was really them that took the
action.
See the DSL documentation for `AshAuthentication` for information on how to
configure it.
"""
defstruct token_lifetime: nil,
monitor_fields: [],
confirmed_at_field: :confirmed_at,
confirm_on_create?: true,
confirm_on_update?: true,
inhibit_updates?: false,
sender: nil,
confirm_action_name: :confirm,
resource: nil,
provider: :confirmation,
name: :confirm
alias Ash.Changeset
alias AshAuthentication.{Jwt, Strategy.Confirmation}
@type t :: %Confirmation{
token_lifetime: hours :: pos_integer,
monitor_fields: [atom],
confirmed_at_field: atom,
confirm_on_create?: boolean,
confirm_on_update?: boolean,
inhibit_updates?: boolean,
sender: nil | {module, keyword},
confirm_action_name: atom,
resource: module,
provider: :confirmation,
name: :confirm
}
@doc """
Generate a confirmation token for a changeset.
This will generate a token with the `"act"` claim set to the confirmation
action for the strategy, and the `"chg"` claim will contain any changes.
FIXME: The "chg" claim should encrypt the contents of the changes so as to not
leak users' private details.
"""
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
def confirmation_token(strategy, changeset) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
token_lifetime = strategy.token_lifetime * 3600
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
end
end
@doc false
@spec schema :: keyword
def schema do
[
token_lifetime: [
type: :pos_integer,
doc: """
How long should the confirmation token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
monitor_fields: [
type: {:list, :atom},
doc: """
A list of fields to monitor for changes (eg `[:email, :phone_number]`).
The confirmation will only be sent when one of these fields are changed.
""",
required: true
],
confirmed_at_field: [
type: :atom,
doc: """
The name of a field to store the time that the last confirmation took
place.
This attribute will be dynamically added to the resource if not already
present.
""",
default: :confirmed_at
],
confirm_on_create?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a new resource is created?
Will only trigger when a create action is executed _and_ one of the
monitored fields is being set.
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a resource is changed?
Will only trigger when an update action is executed _and_ one of the
monitored fields is being set.
""",
default: true
],
inhibit_updates?: [
type: :boolean,
doc: """
Wait until confirmation is received before actually changing a monitored
field?
If a change to a monitored field is detected, then the change is stored
in the confirmation token and the changeset updated to not make the
requested change. When the token is confirmed, the change will be
applied.
This could be potentially weird for your users, but useful in the case
of a user changing their email address or phone number where you want
to verify that the new contact details are reachable.
""",
default: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 2}},
doc: """
How to send the confirmation instructions to the user.
Allows you to glue sending of confirmation instructions to
[swoosh](https://hex.pm/packages/swoosh),
[ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification
system is appropriate for your application.
Accepts a module, module and opts, or a function that takes a record,
reset token and options.
See `AshAuthentication.Sender` for more information.
""",
required: true
],
confirm_action_name: [
type: :atom,
doc: """
The name of the action to use when performing confirmation.
If this action is not already present on the resource, it will be
created for you.
""",
default: :confirm
]
]
end
end

View file

@ -0,0 +1,30 @@
defmodule AshAuthentication.Strategy.Confirmation.Actions do
@moduledoc """
Actions for the confirmation strategy.
Provides the code interface for working with resources via confirmation.
"""
alias Ash.{Changeset, Resource}
alias AshAuthentication.{Errors.InvalidToken, Info, Jwt, Strategy.Confirmation}
@doc """
Attempt to confirm a user.
"""
@spec confirm(Confirmation.t(), map) :: {:ok, Resource.record()} | {:error, any}
def confirm(strategy, params) do
with {:ok, api} <- Info.authentication_api(strategy.resource),
{:ok, token} <- Map.fetch(params, "confirm"),
{:ok, %{"sub" => subject}, _} <- Jwt.verify(token, strategy.resource),
{:ok, user} <- AshAuthentication.subject_to_user(subject, strategy.resource) do
user
|> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_update(strategy.confirm_action_name, params)
|> api.update()
else
:error -> {:error, InvalidToken.exception(type: :confirmation)}
{:error, reason} -> {:error, reason}
end
end
end

View file

@ -0,0 +1,50 @@
defmodule AshAuthentication.Strategy.Confirmation.ConfirmChange do
@moduledoc """
Performs a change based on the contents of a confirmation token.
"""
use Ash.Resource.Change
alias AshAuthentication.Jwt
alias Ash.{
Changeset,
Error.Changes.InvalidArgument,
Error.Framework.AssumptionFailed,
Resource.Change
}
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
case Map.fetch(changeset.context, :strategy) do
{:ok, strategy} ->
do_change(changeset, strategy)
:error ->
raise AssumptionFailed, message: "Strategy is missing from the changeset context."
end
end
defp do_change(changeset, strategy) do
changeset
|> Changeset.before_action(fn changeset ->
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
{:ok, %{"act" => action, "chg" => changes}, _} <-
Jwt.verify(token, changeset.resource),
true <- to_string(strategy.confirm_action_name) == action do
allowed_changes =
if strategy.inhibit_updates?,
do: Map.take(changes, Enum.map(strategy.monitor_fields, &to_string/1)),
else: %{}
changeset
|> Changeset.change_attributes(allowed_changes)
|> Changeset.change_attribute(strategy.confirmed_at_field, DateTime.utc_now())
else
_ ->
raise InvalidArgument, field: :confirm, message: "is not valid"
end
end)
end
end

View file

@ -0,0 +1,97 @@
defmodule AshAuthentication.Strategy.Confirmation.ConfirmationHookChange do
@moduledoc """
Triggers a confirmation flow when one of the monitored fields is changed.
Optionally inhibits changes to monitored fields on update.
"""
use Ash.Resource.Change
alias Ash.{Changeset, Resource.Change}
alias AshAuthentication.{Info, Strategy.Confirmation}
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
case Info.strategy(changeset.resource, :confirm) do
{:ok, strategy} ->
do_change(changeset, strategy)
:error ->
changeset
end
end
defp do_change(changeset, strategy) do
changeset
|> Changeset.before_action(fn changeset ->
changeset
|> not_confirm_action(strategy)
|> should_confirm_action_type(strategy)
|> monitored_field_changing(strategy)
|> changes_would_be_valid()
|> maybe_inhibit_updates(strategy)
|> maybe_perform_confirmation(strategy, changeset)
end)
end
defp not_confirm_action(%Changeset{} = changeset, strategy)
when changeset.action != strategy.confirm_action_name,
do: changeset
defp not_confirm_action(_changeset, _strategy), do: nil
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
when changeset.action_type == :create and strategy.confirm_on_create?,
do: changeset
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
when changeset.action_type == :update and strategy.confirm_on_update?,
do: changeset
defp should_confirm_action_type(_changeset, _strategy), do: nil
defp monitored_field_changing(%Changeset{} = changeset, strategy) do
if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
do: changeset,
else: nil
end
defp monitored_field_changing(_changeset, _strategy), do: nil
defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset
defp changes_would_be_valid(_), do: nil
defp maybe_inhibit_updates(%Changeset{} = changeset, strategy)
when changeset.action_type == :update and strategy.inhibit_updates? do
strategy.monitor_fields
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
end
defp maybe_inhibit_updates(changeset, _strategy), do: changeset
defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset) do
changeset
|> Changeset.after_action(fn _changeset, user ->
strategy
|> Confirmation.confirmation_token(original_changeset)
|> case do
{:ok, token} ->
{sender, send_opts} = strategy.sender
sender.send(user, token, send_opts)
metadata =
user.__metadata__
|> Map.put(:confirmation_token, token)
{:ok, %{user | __metadata__: metadata}}
_ ->
{:ok, user}
end
end)
end
defp maybe_perform_confirmation(_changeset, _strategy, original_changeset),
do: original_changeset
end

View file

@ -0,0 +1,22 @@
defmodule AshAuthentication.Strategy.Confirmation.Plug do
@moduledoc """
Handlers for incoming OAuth2 HTTP requests.
"""
alias AshAuthentication.{Strategy, Strategy.Confirmation}
alias Plug.Conn
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
@doc """
Attempt to perform a confirmation.
"""
@spec confirm(Conn.t(), Confirmation.t()) :: Conn.t()
def confirm(conn, strategy) do
result =
strategy
|> Strategy.action(:confirm, conn.params)
conn
|> store_authentication_result(result)
end
end

View file

@ -0,0 +1,49 @@
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Confirmation do
@moduledoc """
Implementation of `AshAuthentication.Strategy` for
`AshAuthentication.Strategy.Confirmation`.
"""
alias Ash.Resource
alias AshAuthentication.{Info, Strategy, Strategy.Confirmation}
alias Plug.Conn
@typedoc "The request phases supposed by this strategy"
@type phase :: :confirm
@typedoc "The actions supported by this strategy"
@type action :: :confirm
@doc false
@spec phases(Confirmation.t()) :: [phase]
def phases(_), do: [:confirm]
@doc false
@spec actions(Confirmation.t()) :: [action]
def actions(_), do: [:confirm]
@doc false
@spec method_for_phase(Confirmation.t(), phase) :: Strategy.http_method()
def method_for_phase(_, _), do: :get
@doc false
@spec routes(Confirmation.t()) :: [Strategy.route()]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
path =
[subject_name, strategy.name]
|> Enum.map(&to_string/1)
|> Path.join()
[{"/#{path}", :confirm}]
end
@doc false
@spec plug(Confirmation.t(), phase, Conn.t()) :: Conn.t()
def plug(strategy, :confirm, conn), do: Confirmation.Plug.confirm(conn, strategy)
@doc false
@spec action(Confirmation.t(), action, map) :: {:ok, Resource.record()} | {:error, any}
def action(strategy, :confirm, params), do: Confirmation.Actions.confirm(strategy, params)
end

View file

@ -0,0 +1,222 @@
defmodule AshAuthentication.Strategy.Confirmation.Transformer do
@moduledoc """
DSL transformer for confirmation strategy.
Ensures that there is only ever one present and that it is correctly
configured.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Info, Sender, Strategy.Confirmation}
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?(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
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Enum.filter(&is_struct(&1, Confirmation))
|> case do
[] ->
{:ok, dsl_state}
[strategy] ->
transform_strategy(strategy, dsl_state)
[_ | _] ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
message: "Multiple confirmation strategies are not supported"
)}
end
end
defp transform_strategy(strategy, dsl_state) do
with :ok <- validate_token_generation_enabled(dsl_state),
{:ok, {sender, _opts}} <- Map.fetch(strategy, :sender),
:ok <- validate_behaviour(sender, Sender),
:ok <- validate_monitor_fields(dsl_state, strategy),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.confirm_action_name,
&build_confirm_action(&1, strategy)
),
:ok <- validate_confirm_action(dsl_state, strategy),
{:ok, dsl_state} <-
maybe_build_attribute(
dsl_state,
strategy.confirmed_at_field,
&build_confirmed_at_attribute(&1, strategy)
),
:ok <- validate_confirmed_at_attribute(dsl_state, strategy),
{:ok, dsl_state} <- maybe_build_change(dsl_state, Confirmation.ConfirmationHookChange),
{:ok, resource} <- persisted_option(dsl_state, :module) do
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
%{strategy | resource: resource},
&(&1.name == strategy.name)
)
{:ok, dsl_state}
else
{:error, reason} when is_binary(reason) ->
{:error,
DslError.exception(path: [:authentication, :strategies, :confirmation], message: reason)}
{:error, reason} ->
{:error, reason}
:error ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
message: "Configuration error"
)}
end
end
defp validate_monitor_fields(_dsl_state, %{monitor_fields: []}),
do:
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
message: "You should be monitoring at least one field"
)}
defp validate_monitor_fields(dsl_state, strategy) do
Enum.reduce_while(strategy.monitor_fields, :ok, fn field, :ok ->
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, attribute} <- find_attribute(dsl_state, field),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
:ok <- maybe_validate_eager_checking(dsl_state, strategy, field, resource) do
{:cont, :ok}
else
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp maybe_validate_eager_checking(_dsl_state, %{inhibit_updates?: false}, _, _), do: :ok
defp maybe_validate_eager_checking(dsl_state, _strategy, field, resource) do
dsl_state
|> Resource.Info.identities()
|> Enum.find(&(&1.keys == [field]))
|> case do
%{eager_check_with: nil} ->
{:error,
DslError.exception(
path: [:identities, :identity],
message:
"The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated."
)}
_ ->
:ok
end
end
defp build_confirm_action(_dsl_state, strategy) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
name: :confirm,
type: Type.String,
allow_nil?: false
)
]
changes = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: Confirmation.ConfirmChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: GenerateTokenChange
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: strategy.confirm_action_name,
accept: strategy.monitor_fields,
arguments: arguments,
changes: changes
)
end
defp validate_confirm_action(dsl_state, strategy) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.confirm_action_name),
:ok <- validate_action_has_change(action, Confirmation.ConfirmChange),
:ok <- validate_action_argument_option(action, :confirm, :allow_nil?, [false]),
:ok <- validate_action_has_change(action, GenerateTokenChange) do
validate_action_argument_option(action, :confirm, :type, [Type.String])
end
end
defp build_confirmed_at_attribute(_dsl_state, strategy) do
Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
name: strategy.confirmed_at_field,
type: Type.UtcDatetimeUsec,
allow_nil?: true,
writable?: true
)
end
defp validate_confirmed_at_attribute(dsl_state, strategy) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, attribute} <- find_attribute(dsl_state, strategy.confirmed_at_field),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]),
:ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do
:ok
else
:error ->
{:error,
DslError.exception(
path: [:confirmation],
message: "The `confirmed_at_field` option must be set."
)}
{:error, reason} ->
{:error, reason}
end
end
defp maybe_build_change(dsl_state, change_module) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
changes <- Resource.Info.changes(resource),
false <- change_module in changes,
{:ok, change} <-
Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
else
true -> {:ok, dsl_state}
{:error, reason} -> {:error, reason}
end
end
end

View file

@ -0,0 +1,272 @@
defmodule AshAuthentication.Strategy.OAuth2 do
import AshAuthentication.Dsl
@moduledoc """
Strategy for authenticating using an OAuth 2.0 server as the source of truth.
This strategy wraps the excellent [`assent`](https://hex.pm/packages/assent)
package, which provides OAuth 2.0 capabilities.
In order to use OAuth 2.0 authentication on your resource, it needs to meet
the following minimum criteria:
1. Have a primary key.
2. Provide a strategy-specific action, either register or sign-in.
3. Provide configuration for OAuth2 destinations, secrets, etc.
### Example:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
authentication do
api MyApp.Accounts
strategies do
oauth2 :example do
client_id "OAuth Client ID"
redirect_uri "https://my.app/"
client_secret "My Super Secret Secret"
site "https://auth.example.com/"
end
end
end
```
## Secrets and runtime configuration
In order to use OAuth 2.0 you need to provide a varying number of secrets and
other configuration which may change based on runtime environment. The
`AshAuthentication.Secret` behaviour is provided to accomodate this. This
allows you to provide configuration either directly on the resource (ie as a
string), as an anonymous function, or as a module.
> ### Warning {: .warning}
>
> We **strongly** urge you not to sure actual secrets in your code or
> repository.
### Examples:
Providing configuration as an anonymous function:
```elixir
oauth2 do
client_secret fn _path, resource ->
Application.fetch_env(:my_app, resource, :oauth2_client_secret)
end
end
```
Providing configuration as a module:
```elixir
defmodule MyApp.Secrets do
use AshAuthentication.Secret
def secret_for([:authentication, :strategies, :example, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth2_client_secret)
end
# and in your stragegies:
oauth2 :example do
client_secret MyApp.Secrets
end
```
## User identities
Because your users can be signed in via multiple providers at once, you can
specify an `identity_resource` in the DSL configuration which points to a
seperate Ash resource which has the `AshAuthentication.UserIdentity` extension
present. This resource will be used to store details of the providers in use
by each user and a relationship will be added to the user resource.
Setting the `identity_resource` will cause extra validations to be applied to
your resource so that changes are tracked correctly on sign-in or
registration.
## Actions
When using an OAuth 2.0 provider you need to declare either a "register" or
"sign-in" action. The reason for this is that it's not possible for us to
know ahead of time how you want to manage the link between your user resources
and the "user info" provided by the OAuth server.
Both actions receive the following two arguments:
1. `user_info` - a map with string keys containing the [OpenID Successful
UserInfo
response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse).
Usually this will be used to populate your email, nickname or other
identifying field.
2. `oauth_tokens` a map with string keys containing the [OpenID Successful
Token
response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse)
(or similar).
The actions themselves can be interacted with directly via the
`AshAuthentication.Strategy` protocol, but you are more likely to interact
with them via the web/plugs.
### Sign-in
The sign-in action is called when a successful OAuth2 callback is received.
You should use it to constrain the query to the correct user based on the
arguments provided.
This action is only needed when the `registration_enabled?` DSL settings is
set to `false`.
### Registration
The register action is a little more complicated than the sign-in action,
because we cannot tell the difference between a new user and a returning user
(they all use the same OAuth flow). In order to handle this your register
action must be defined as an upset with a configured `upsert_identity` (see
example below).
### Examples:
Providing sign-in to users who already exist in the database (and by extension
rejecting new users):
```elixir
defmodule MyApp.Accounts.User do
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
actions do
read :sign_in_with_example do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
filter expr(email == get_path(^arg(:user_info), [:email]))
end
end
authentication do
api MyApp.Accounts
strategies do
oauth2 :example do
registration_enabled? false
end
end
end
```
Providing registration or sign-in to all comers:
```elixir
defmodule MyApp.Accounts.User do
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
actions do
create :register_with_oauth2 do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :email
change AshAuthentication.GenerateTokenChange
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
changeset
|> Changeset.change_attribute(:email, user_info["email"])
end
end
end
authentication do
api MyApp.Accounts
strategies do
oauth2 :example do
end
end
end
```
## Plugs
OAuth 2.0 is (usually) a browser-based flow. This means that you're most
likely to interact with this strategy via it's plugs. There are two phases to
authentication with OAuth 2.0:
1. The request phase, where the user's browser is redirected to the remote
authentication provider for authentication.
2. The callback phase, where the provider redirects the user back to your app
to create a local database record, session, etc.
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:oauth2))}
"""
defstruct client_id: nil,
site: nil,
auth_method: :client_secret_post,
client_secret: nil,
authorize_path: nil,
token_path: nil,
user_path: nil,
private_key: nil,
redirect_uri: nil,
authorization_params: [],
registration_enabled?: true,
register_action_name: nil,
sign_in_action_name: nil,
identity_resource: false,
identity_relationship_name: :identities,
identity_relationship_user_id_attribute: :user_id,
provider: :oauth2,
name: nil,
resource: nil
alias AshAuthentication.Strategy.OAuth2
@type secret :: nil | String.t() | {module, keyword}
@type t :: %OAuth2{
client_id: secret,
site: secret,
auth_method:
nil
| :client_secret_basic
| :client_secret_post
| :client_secret_jwt
| :private_key_jwt,
client_secret: secret,
authorize_path: secret,
token_path: secret,
user_path: secret,
private_key: secret,
redirect_uri: secret,
authorization_params: keyword,
registration_enabled?: boolean,
register_action_name: atom,
sign_in_action_name: atom,
identity_resource: module | false,
identity_relationship_name: atom,
identity_relationship_user_id_attribute: atom,
provider: atom,
name: atom,
resource: module
}
end

View file

@ -0,0 +1,64 @@
defmodule AshAuthentication.Strategy.OAuth2.Actions do
@moduledoc """
Actions for the oauth2 strategy.
Provides the code interface for working with resources via an OAuth2 strategy.
"""
alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
alias AshAuthentication.{Errors, Info, Strategy.OAuth2}
@doc """
Attempt to sign in a user.
"""
@spec sign_in(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any}
def sign_in(%OAuth2{} = strategy, _params) when strategy.registration_enabled?,
do:
{:error,
NoSuchAction.exception(
resource: strategy.resource,
action: strategy.sign_in_action_name,
type: :read
)}
def sign_in(%OAuth2{} = strategy, params) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(strategy.sign_in_action_name, params)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
_ -> {:error, Errors.AuthenticationFailed.exception([])}
end
end
@doc """
Attempt to register a new user.
"""
@spec register(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any}
def register(%OAuth2{} = strategy, params) when strategy.registration_enabled? do
api = Info.authentication_api!(strategy.resource)
action = Resource.Info.action(strategy.resource, strategy.register_action_name, :create)
strategy.resource
|> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(strategy.register_action_name, params,
upsert?: true,
upsert_identity: action.upsert_identity
)
|> api.create()
end
def register(%OAuth2{} = strategy, _params),
do:
{:error,
NoSuchAction.exception(
resource: strategy.resource,
action: strategy.register_action_name,
type: :create
)}
end

View file

@ -0,0 +1,18 @@
defmodule AshAuthentication.Strategy.OAuth2.Default do
@moduledoc """
Sets default values for values which can be configured at runtime and are not set.
"""
use AshAuthentication.Secret
@doc false
@impl true
@spec secret_for([atom], Ash.Resource.t(), keyword) :: {:ok, String.t()} | :error
def secret_for(path, _resource, _opts), do: path |> Enum.reverse() |> List.first() |> default()
@doc false
@spec default(atom) :: {:ok, String.t()}
def default(:authorize_path), do: {:ok, "/oauth/authorize"}
def default(:token_path), do: {:ok, "/oauth/access_token"}
def default(:user_path), do: {:ok, "/user"}
end

View file

@ -0,0 +1,47 @@
defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
@moduledoc """
Updates the identity resource when a user is registered.
"""
use Ash.Resource.Change
alias AshAuthentication.UserIdentity
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
import AshAuthentication.Utils, only: [is_falsy: 1]
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
case Map.fetch(changeset.context, :strategy) do
{:ok, strategy} ->
do_change(changeset, strategy)
:error ->
{:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
end
end
defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset
defp do_change(changeset, strategy) do
changeset
|> Changeset.after_action(fn changeset, user ->
strategy.identity_resource
|> UserIdentity.Actions.upsert(%{
user_info: Changeset.get_argument(changeset, :user_info),
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
strategy: strategy.name,
user_id: user.id
})
|> case do
{:ok, _identity} ->
user
|> changeset.api.load(strategy.identity_relationship_name)
{:error, reason} ->
{:error, reason}
end
end)
end
end

View file

@ -0,0 +1,155 @@
defmodule AshAuthentication.Strategy.OAuth2.Plug do
@moduledoc """
Handlers for incoming OAuth2 HTTP requests.
"""
alias Ash.Error.Framework.AssumptionFailed
alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2}
alias Assent.{Config, HTTPAdapter.Mint}
alias Assent.Strategy.OAuth2, as: Assent
alias Plug.Conn
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
import Plug.Conn
@doc """
Perform the request phase of OAuth2.
Builds a redirection URL based on the provider configuration and redirects the
user to that endpoint.
"""
@spec request(Conn.t(), OAuth2.t()) :: Conn.t()
def request(conn, strategy) do
with {:ok, config} <- config_for(strategy),
{:ok, session_key} <- session_key(strategy),
{:ok, %{session_params: session_params, url: url}} <- Assent.authorize_url(config) do
conn
|> put_session(session_key, session_params)
|> put_resp_header("location", url)
|> send_resp(:found, "Redirecting to #{strategy.name}")
else
{:error, reason} -> store_authentication_result(conn, {:error, reason})
end
end
@doc """
Perform the callback phase of OAuth2.
Responds to a user being redirected back from the remote authentication
provider, and validates the passed options, ultimately registering or
signing-in a user if the authentication was successful.
"""
@spec callback(Conn.t(), OAuth2.t()) :: Conn.t()
def callback(conn, strategy) do
with {:ok, session_key} <- session_key(strategy),
{:ok, config} <- config_for(strategy),
session_params when is_map(session_params) <- get_session(conn, session_key),
conn <- delete_session(conn, session_key),
config <- Config.put(config, :session_params, session_params),
{:ok, %{user: user, token: token}} <- Assent.callback(config, conn.params),
{:ok, user} <-
register_or_sign_in_user(strategy, %{user_info: user, oauth_tokens: token}) do
store_authentication_result(conn, {:ok, user})
else
nil -> store_authentication_result(conn, {:error, nil})
{:error, reason} -> store_authentication_result(conn, {:error, reason})
end
end
defp config_for(strategy) do
with {:ok, client_id} <- fetch_secret(strategy, :client_id),
{:ok, site} <- fetch_secret(strategy, :site),
{:ok, redirect_uri} <- build_redirect_uri(strategy),
{:ok, authorize_url} <- build_uri(strategy, :authorize_path),
{:ok, token_url} <- build_uri(strategy, :token_path),
{:ok, user_url} <- build_uri(strategy, :user_path) do
config =
[
auth_method: strategy.auth_method,
client_id: client_id,
client_secret: get_secret(strategy, :client_secret),
private_key: get_secret(strategy, :private_key),
jwt_algorithm: Info.authentication_tokens_signing_algorithm(strategy.resource),
authorization_params: strategy.authorization_params,
redirect_uri: redirect_uri,
site: site,
authorize_url: authorize_url,
token_url: token_url,
user_url: user_url,
http_adapter: Mint
]
|> Enum.reject(&is_nil(elem(&1, 1)))
{:ok, config}
end
end
defp register_or_sign_in_user(strategy, params) when strategy.registration_enabled?,
do: Strategy.action(strategy, :register, params)
defp register_or_sign_in_user(strategy, params), do: Strategy.action(strategy, :sign_in, params)
# We need to temporarily store some information about the request in the
# session so that we can verify that there hasn't been a CSRF-related attack.
defp session_key(strategy) do
case Info.authentication_subject_name(strategy.resource) do
{:ok, subject_name} ->
{:ok, "#{subject_name}/#{strategy.name}"}
:error ->
{:error,
AssumptionFailed.exception(
message: "Resource `#{inspect(strategy.resource)}` has no subject name"
)}
end
end
defp fetch_secret(strategy, secret_name) do
path = [:authentication, :strategies, strategy.name, secret_name]
with {:ok, {secret_module, secret_opts}} <- Map.fetch(strategy, secret_name),
{:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <-
secret_module.secret_for(path, strategy.resource, secret_opts) do
{:ok, secret}
else
{:ok, secret} when is_binary(secret) -> {:ok, secret}
_ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}
end
end
defp get_secret(strategy, secret_name) do
case fetch_secret(strategy, secret_name) do
{:ok, secret} -> secret
_ -> nil
end
end
defp build_redirect_uri(strategy) do
with {:ok, subject_name} <- Info.authentication_subject_name(strategy.resource),
{:ok, redirect_uri} <- fetch_secret(strategy, :redirect_uri),
{:ok, uri} <- URI.new(redirect_uri) do
path =
Path.join([uri.path || "/", to_string(subject_name), to_string(strategy.name), "callback"])
{:ok, to_string(%URI{uri | path: path})}
else
:error ->
{:error,
AssumptionFailed.exception(
message: "Resource `#{inspect(strategy.resource)}` has no subject name"
)}
{:error, reason} ->
{:error, reason}
end
end
defp build_uri(strategy, secret_name) do
with {:ok, site} <- fetch_secret(strategy, :site),
{:ok, uri} <- URI.new(site),
{:ok, path} <- fetch_secret(strategy, secret_name) do
path = Path.join(uri.path || "/", path)
{:ok, to_string(%URI{uri | path: path})}
end
end
end

View file

@ -0,0 +1,70 @@
defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
@moduledoc """
Prepare a query for sign in
Performs three main tasks:
1. Ensures that there is only one matching user record returned, otherwise
returns an authentication failed error.
2. Generates an access token if token generation is enabled.
3. Updates the user identity resource, if one is enabled.
"""
use Ash.Resource.Preparation
alias Ash.{Error.Framework.AssumptionFailed, Query, Resource.Preparation}
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, UserIdentity}
require Ash.Query
import AshAuthentication.Utils, only: [is_falsy: 1]
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do
case Map.fetch(query.context, :strategy) do
:error ->
{:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
{:ok, strategy} ->
query
|> Query.after_action(fn
query, [user] ->
with {:ok, user} <- maybe_update_identity(user, query, strategy) do
{:ok, [maybe_generate_token(user)]}
end
_, _ ->
{:error, AuthenticationFailed.exception(query: query)}
end)
end
end
defp maybe_update_identity(user, _query, strategy) when is_falsy(strategy.identity_resource),
do: user
defp maybe_update_identity(user, query, strategy) do
strategy.identity_resource
|> UserIdentity.Actions.upsert(%{
user_info: Query.get_argument(query, :user_info),
oauth_tokens: Query.get_argument(query, :oauth_tokens),
strategy: strategy.name,
user_id: user.id
})
|> case do
{:ok, _identity} ->
user
|> query.api.load(strategy.identity_relationship_name)
{:error, reason} ->
{:error, reason}
end
end
defp maybe_generate_token(user) do
if AshAuthentication.Info.authentication_tokens_enabled?(user.__struct__) do
{:ok, token, _claims} = Jwt.token_for_user(user)
%{user | __metadata__: Map.put(user.__metadata__, :token, token)}
else
user
end
end
end

View file

@ -0,0 +1,62 @@
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.OAuth2 do
@moduledoc """
Implmentation of `AshAuthentication.Strategy` for
`AshAuthentication.Strategy.OAuth2`.
"""
alias Ash.Resource
alias AshAuthentication.{Info, Strategy, Strategy.OAuth2}
alias Plug.Conn
@typedoc "The request phases supported by this strategy"
@type phase :: :request | :callback
@typedoc "The actions supported by this strategy"
@type action :: :register | :sign_in
@doc false
@spec phases(OAuth2.t()) :: [phase]
def phases(_), do: [:request, :callback]
@doc false
@spec actions(OAuth2.t()) :: [action]
def actions(%OAuth2{registration_enabled?: true}), do: [:register]
def actions(%OAuth2{registration_enabled?: false}), do: [:sign_in]
@doc false
@spec method_for_phase(OAuth2.t(), phase) :: Strategy.http_method()
def method_for_phase(_, :request), do: :get
def method_for_phase(_, :callback), do: :post
@doc """
Return a list of routes for use by the strategy.
"""
@spec routes(OAuth2.t()) :: [Strategy.route()]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[request: nil, callback: :callback]
|> Enum.map(fn {phase, suffix} ->
path =
[subject_name, strategy.name, suffix]
|> Enum.map(&to_string/1)
|> Path.join()
{"/#{path}", phase}
end)
end
@doc """
Handle HTTP requests.
"""
@spec plug(OAuth2.t(), phase, Conn.t()) :: Conn.t()
def plug(strategy, :request, conn), do: OAuth2.Plug.request(conn, strategy)
def plug(strategy, :callback, conn), do: OAuth2.Plug.callback(conn, strategy)
@doc """
Perform actions.
"""
@spec action(OAuth2.t(), action, map) :: {:ok, Resource.record()} | {:error, any}
def action(strategy, :register, params), do: OAuth2.Actions.register(strategy, params)
def action(strategy, :sign_in, params), do: OAuth2.Actions.sign_in(strategy, params)
end

View file

@ -0,0 +1,173 @@
defmodule AshAuthentication.Strategy.OAuth2.Transformer do
@moduledoc """
DSL transformer for oauth2 strategies.
Iterates through any oauth2 strategies and ensures that all the correct
actions and settings are in place.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Info, Strategy.OAuth2}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
@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
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, OAuth2))
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
with strategy <- set_defaults(strategy),
{:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, strategy),
:ok <- maybe_validate_register_action(dsl_state, strategy),
:ok <- maybe_validate_sign_in_action(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
%{strategy | resource: resource},
&(&1.name == strategy.name)
)
{:ok, dsl_state}
else
{:error, reason} when is_binary(reason) ->
{:error,
DslError.exception(path: [:authentication, :strategies, strategy.name], message: reason)}
{:error, reason} ->
{:error, reason}
end
end
defp set_defaults(strategy) do
default_secret = {OAuth2.Default, []}
strategy
|> maybe_set_field(:authorize_path, default_secret)
|> maybe_set_field(:token_path, default_secret)
|> maybe_set_field(:user_path, default_secret)
|> maybe_set_field_lazy(:register_action_name, &:"register_with_#{&1.name}")
|> maybe_set_field_lazy(:sign_in_action_name, &:"sign_in_with_#{&1.name}")
end
defp maybe_build_identity_relationship(dsl_state, strategy)
when is_falsy(strategy.identity_resource),
do: {:ok, dsl_state}
defp maybe_build_identity_relationship(dsl_state, strategy) do
maybe_build_relationship(
dsl_state,
strategy.identity_relationship_name,
&build_identity_relationship(&1, strategy)
)
end
defp build_identity_relationship(_dsl_state, strategy) do
Transformer.build_entity(Resource.Dsl, [:relationships], :has_many,
name: strategy.identity_relationship_name,
destination: strategy.identity_resource,
destination_attribute: strategy.identity_relationship_user_id_attribute
)
end
defp maybe_validate_register_action(dsl_state, strategy) when strategy.registration_enabled? do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name),
:ok <- validate_action_has_argument(action, :user_info),
:ok <- validate_action_argument_option(action, :user_info, :type, [Type.Map, :map]),
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
:ok <- validate_action_has_argument(action, :oauth_tokens),
:ok <-
validate_action_argument_option(action, :oauth_tokens, :type, [Type.Map, :map]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
:ok <- maybe_validate_action_has_token_change(dsl_state, action),
:ok <- validate_field_in_values(action, :upsert?, [true]),
:ok <-
validate_field_with(
action,
:upsert_identity,
&(is_atom(&1) and not is_falsy(&1)),
"Expected `upsert_identity` to be set"
),
:ok <- maybe_validate_action_has_identity_change(action, strategy) do
:ok
else
:error ->
{:error, "Unable to validate register action"}
{:error, reason} when is_binary(reason) ->
{:error, "`#{inspect(strategy.register_action_name)}` action: #{reason}"}
{:error, reason} ->
{:error, reason}
end
end
defp maybe_validate_register_action(_dsl_state, _strategy), do: :ok
defp maybe_validate_action_has_token_change(dsl_state, action) do
if Info.authentication_tokens_enabled?(dsl_state) do
validate_action_has_change(action, GenerateTokenChange)
else
:ok
end
end
defp maybe_validate_action_has_identity_change(_action, strategy)
when is_falsy(strategy.identity_resource),
do: :ok
defp maybe_validate_action_has_identity_change(action, _strategy),
do: validate_action_has_change(action, OAuth2.IdentityChange)
defp maybe_validate_sign_in_action(_dsl_state, strategy) when strategy.registration_enabled?,
do: :ok
defp maybe_validate_sign_in_action(dsl_state, strategy) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name),
:ok <- validate_action_has_argument(action, :user_info),
:ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
:ok <- validate_action_has_argument(action, :oauth_tokens),
:ok <-
validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
:ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do
:ok
else
:error -> {:error, "Unable to validate sign in action"}
{:error, reason} -> {:error, reason}
end
end
end

View file

@ -0,0 +1,148 @@
defmodule AshAuthentication.Strategy.Password do
import AshAuthentication.Dsl
@moduledoc """
Strategy for authenticating using local resources as the source of truth.
In order to use password authentication your resource needs to meet the
following minimum requirements:
1. Have a primary key.
2. A uniquely constrained identity field (eg `username` or `email`).
3. A sensitive string field within which to store the hashed password.
There are other options documented in the DSL.
### Example:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication]
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
api MyApp.Accounts
strategies do
password do
identity_field :email
hashed_password_field :hashed_password
end
end
end
identities do
identity :unique_email, [:email]
end
end
```
## Actions
By default the password strategy will automatically generate the register,
sign-in, reset-request and reset actions for you, however you're free to
define them yourself. If you do, then the action will be validated to ensure
that all the needed configuration is present.
If you wish to work with the actions directly from your code you can do so via
the `AshAuthentication.Strategy` protocol.
### Examples:
Interacting with the actions directly:
iex> strategy = Info.strategy!(Example.User, :password)
...> {:ok, marty} = Strategy.action(strategy, :register, %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"})
...> marty.username |> to_string()
"marty"
...> {:ok, user} = Strategy.action(strategy, :sign_in, %{"username" => "outatime1985", "password" => "outatime1985"})
...> user.username |> to_string()
"marty"
## Plugs
The password strategy provides plug endpoints for all four actions, although
only sign-in and register will be reported by `Strategy.routes/1` if the
strategy is not configured as resettable.
If you wish to work with the plugs directly, you can do so via the
`AshAuthentication.Strategy` protocol.
### Examples:
Dispatching to plugs directly:
iex> strategy = Info.strategy!(Example.User, :password)
...> conn = conn(:post, "/user/password/register", %{"user" => %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"}})
...> conn = Strategy.plug(strategy, :register, conn)
...> {_conn, {:ok, marty}} = Plug.Helpers.get_authentication_result(conn)
...> marty.username |> to_string()
"marty"
...> conn = conn(:post, "/user/password/reset_request", %{"user" => %{"username" => "marty"}})
...> conn = Strategy.plug(strategy, :reset_request, conn)
...> {_conn, :ok} = Plug.Helpers.get_authentication_result(conn)
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:password))}
"""
defstruct identity_field: :username,
hashed_password_field: :hashed_password_field,
hash_provider: AshAuthentication.BcryptProvider,
confirmation_required?: false,
password_field: :password,
password_confirmation_field: :password_confirmation,
register_action_name: nil,
sign_in_action_name: nil,
resettable: [],
name: nil,
provider: :password,
resource: nil
alias Ash.Resource
alias AshAuthentication.{Jwt, Strategy.Password}
@type t :: %Password{
identity_field: atom,
hashed_password_field: atom,
hash_provider: module,
confirmation_required?: boolean,
password_field: atom,
password_confirmation_field: atom,
register_action_name: atom,
sign_in_action_name: atom,
resettable: [Password.Resettable.t()],
name: atom,
provider: atom,
resource: module
}
@doc """
Generate a reset token for a user.
Used by `AshAuthentication.Strategy.Password.RequestPasswordResetPreparation`.
"""
@spec reset_token_for(t(), Resource.record()) :: {:ok, String.t()} | :error
def reset_token_for(
%Password{resettable: [%Password.Resettable{} = resettable]} = _strategy,
user
) do
case Jwt.token_for_user(user, %{"act" => resettable.password_reset_action_name},
token_lifetime: resettable.token_lifetime * 3600
) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
end
end
def reset_token_for(_strategy, _user), do: :error
end

View file

@ -0,0 +1,94 @@
defmodule AshAuthentication.Strategy.Password.Actions do
@moduledoc """
Actions for the password strategy
Provides the code interface for working with resources via a password
strategy.
"""
alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
alias AshAuthentication.{Errors, Info, Jwt, Strategy.Password}
@doc """
Attempt to sign in a user.
"""
@spec sign_in(Password.t(), map) ::
{:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
def sign_in(%Password{} = strategy, params) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(strategy.sign_in_action_name, params)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
_ -> {:error, Errors.AuthenticationFailed.exception([])}
end
end
@doc """
Attempt to register a new user.
"""
@spec register(Password.t(), map) :: {:ok, Resource.record()} | {:error, any}
def register(%Password{} = strategy, params) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(strategy.register_action_name, params)
|> api.create()
end
@doc """
Request a password reset.
"""
@spec reset_request(Password.t(), map) :: :ok | {:error, any}
def reset_request(
%Password{resettable: [%Password.Resettable{} = resettable]} = strategy,
params
) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(resettable.request_password_reset_action_name, params)
|> api.read()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
def reset_request(%Password{} = strategy, _params),
do:
{:error,
NoSuchAction.exception(resource: strategy.resource, action: :reset_request, type: :read)}
@doc """
Attempt to change a user's password using a reset token.
"""
@spec reset(Password.t(), map) :: {:ok, Resource.record()} | {:error, any}
def reset(%Password{resettable: [%Password.Resettable{} = resettable]} = strategy, params) do
with {:ok, token} <- Map.fetch(params, "reset_token"),
{:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, strategy.resource),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
api = Info.authentication_api!(resource)
user
|> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_update(resettable.password_reset_action_name, params)
|> api.update()
else
{:error, %Changeset{} = changeset} -> {:error, changeset}
_ -> {:error, Errors.InvalidToken.exception(type: :reset)}
end
end
def reset(%Password{} = strategy, _params),
do: {:error, NoSuchAction.exception(resource: strategy.resource, action: :reset, type: :read)}
end

View file

@ -0,0 +1,32 @@
defmodule AshAuthentication.Strategy.Password.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 Ash.{Changeset, Error.Framework.AssumptionFailed, 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 ->
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy),
value when is_binary(value) <-
Changeset.get_argument(changeset, strategy.password_field),
{:ok, hash} <- strategy.hash_provider.hash(value) do
Changeset.change_attribute(changeset, strategy.hashed_password_field, hash)
else
:error ->
raise AssumptionFailed, message: "Error hashing password."
_ ->
changeset
end
end)
end
end

View file

@ -0,0 +1,45 @@
defmodule AshAuthentication.Strategy.Password.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, Error.Framework.AssumptionFailed}
@doc """
Validates that the password and password confirmation fields contain
equivalent values - if confirmation is required.
"""
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
def validate(changeset, _) do
case Map.fetch(changeset.context, :strategy) do
{:ok, %{confirmation_required?: true} = strategy} ->
validate_password_confirmation(changeset, strategy)
{:ok, _} ->
:ok
:error ->
{:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}
end
end
defp validate_password_confirmation(changeset, strategy) do
password = Changeset.get_argument(changeset, strategy.password_field)
confirmation = Changeset.get_argument(changeset, strategy.password_confirmation_field)
if password == confirmation do
:ok
else
{:error,
InvalidArgument.exception(
field: strategy.password_confirmation_field,
message: "does not match"
)}
end
end
end

View file

@ -0,0 +1,80 @@
defmodule AshAuthentication.Strategy.Password.Plug do
@moduledoc """
Plugs for the password strategy.
Handles registration, sign-in and password resets.
"""
alias AshAuthentication.{Info, Strategy, Strategy.Password}
alias Plug.Conn
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
@doc "Handle a registration request"
@spec register(Conn.t(), Password.t()) :: Conn.t()
def register(conn, strategy) do
params =
conn
|> subject_params(strategy)
result =
strategy
|> Strategy.action(:register, params)
conn
|> store_authentication_result(result)
end
@doc "Handle a sign-in request"
@spec sign_in(Conn.t(), Password.t()) :: Conn.t()
def sign_in(conn, strategy) do
params =
conn
|> subject_params(strategy)
result =
strategy
|> Strategy.action(:sign_in, params)
conn
|> store_authentication_result(result)
end
@doc "Handle a reset request request"
@spec reset_request(Conn.t(), Password.t()) :: Conn.t()
def reset_request(conn, strategy) do
params =
conn
|> subject_params(strategy)
result =
strategy
|> Strategy.action(:reset_request, params)
conn
|> store_authentication_result(result)
end
@doc "Handle a reset request"
@spec reset(Conn.t(), Password.t()) :: Conn.t()
def reset(conn, strategy) do
params =
conn
|> subject_params(strategy)
result =
strategy
|> Strategy.action(:reset, params)
conn
|> store_authentication_result(result)
end
defp subject_params(conn, strategy) do
subject_name =
strategy.resource
|> Info.authentication_subject_name!()
|> to_string()
Map.get(conn.params, subject_name, %{})
end
end

View file

@ -0,0 +1,44 @@
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
@moduledoc """
Prepare a query for a password reset request.
This preparation performs three jobs, one before the query executes and two
after.
Firstly, it constraints the query to match the identity field passed to the
action.
Secondly, if there is a user returned by the query, then generate a reset
token and publish a notification. Always returns an empty result.
"""
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.Strategy.Password
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do
strategy = Map.fetch!(query.context, :strategy)
if Enum.any?(strategy.resettable) do
identity_field = strategy.identity_field
identity = Query.get_argument(query, identity_field)
query
|> Query.filter(ref(^identity_field) == ^identity)
|> Query.after_action(&after_action(&1, &2, strategy))
else
query
end
end
defp after_action(_query, [user], %{resettable: [%{sender: {sender, send_opts}}]} = strategy) do
case Password.reset_token_for(strategy, user) do
{:ok, token} -> sender.send(user, token, send_opts)
_ -> nil
end
{:ok, []}
end
defp after_action(_query, _, _), do: {:ok, []}
end

View file

@ -1,20 +1,21 @@
defmodule AshAuthentication.PasswordReset.ResetTokenValidation do
defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do
@moduledoc """
Validate that the token is a valid password reset request token.
"""
use Ash.Resource.Validation
alias Ash.{Changeset, Error.Changes.InvalidArgument}
alias AshAuthentication.{Jwt, PasswordReset.Info}
alias AshAuthentication.Jwt
@doc false
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()}
def validate(changeset, _) do
with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy),
token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
{:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource),
{:ok, resource_action} <- Info.password_reset_action_name(changeset.resource),
true <- to_string(resource_action) == token_action do
{:ok, [resettable]} <- Map.fetch(strategy, :resettable),
true <- to_string(resettable.password_reset_action_name) == token_action do
:ok
else
_ ->

View file

@ -0,0 +1,76 @@
defmodule AshAuthentication.Strategy.Password.Resettable do
@moduledoc """
The entity used to store password reset information.
"""
@default_lifetime_days 3
defstruct token_lifetime: @default_lifetime_days * 24,
request_password_reset_action_name: nil,
password_reset_action_name: nil,
sender: nil
@type t :: %__MODULE__{
token_lifetime: hours :: pos_integer,
request_password_reset_action_name: atom,
password_reset_action_name: atom,
sender: {module, keyword}
}
@doc false
@spec entity :: struct()
def entity do
%Spark.Dsl.Entity{
name: :resettable,
describe: "Configure password reset options for the resource",
target: __MODULE__,
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the reset token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
request_password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which generates a password reset token.
If not present it will be generated by prepending the strategy name
with `request_password_reset_with_`.
""",
required: false
],
password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which actually resets the user's
password.
If not present it will be generated by prepending the strategy name
with `password_reset_with_`.
""",
required: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 2}},
doc: """
How to send the password reset instructions to the user.
Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application.
Accepts a module, module and opts, or a function that takes a record, reset token and options.
See `AshAuthentication.Sender` for more information.
""",
required: true
]
]
}
end
end

View file

@ -1,4 +1,4 @@
defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
defmodule AshAuthentication.Strategy.Password.SignInPreparation do
@moduledoc """
Prepare a query for sign in
@ -13,32 +13,30 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
an authentication failed error.
"""
use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, PasswordAuthentication.Info}
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt}
alias Ash.{Query, Resource.Preparation}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _) do
{:ok, identity_field} = Info.password_authentication_identity_field(query.resource)
{:ok, password_field} = Info.password_authentication_password_field(query.resource)
{:ok, hasher} = Info.password_authentication_hash_provider(query.resource)
def prepare(query, _opts, _context) do
strategy = Map.fetch!(query.context, :strategy)
identity_field = strategy.identity_field
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)
password = Query.get_argument(query, strategy.password_field)
if hasher.valid?(password, record.hashed_password),
if strategy.hash_provider.valid?(password, record.hashed_password),
do: {:ok, [maybe_generate_token(record)]},
else: auth_failed(query)
_, _ ->
hasher.simulate()
strategy.hash_provider.simulate()
auth_failed(query)
end)
end
@ -46,8 +44,8 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
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)
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
{:ok, token, _claims} = Jwt.token_for_user(record)
%{record | __metadata__: Map.put(record.__metadata__, :token, token)}
else
record

View file

@ -0,0 +1,73 @@
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
@moduledoc """
Implementation of `AshAuthentication.Strategy` for
`AshAuthentication.Strategy.Password`.
Because the password strategy can optionally provide password reset
functionality it provides more than the usual number of routes, actions, etc.
"""
alias Ash.Resource
alias AshAuthentication.{Info, Strategy, Strategy.Password}
alias Plug.Conn
@typedoc """
The possible request phases for the password strategy.
Only the first two will be used if password resets are disabled.
"""
@type phase :: :register | :sign_in | :reset_request | :reset
@doc false
@spec phases(Password.t()) :: [phase]
def phases(%{resettable: []}), do: [:register, :sign_in]
def phases(_strategy), do: [:register, :sign_in, :reset_request, :reset]
@doc false
@spec actions(Password.t()) :: [phase]
def actions(strategy), do: phases(strategy)
@doc false
@spec method_for_phase(Password.t(), phase) :: Strategy.http_method()
def method_for_phase(_, _), do: :post
@doc """
Return a list of routes for use by the strategy.
"""
@spec routes(Password.t()) :: [Strategy.route()]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
strategy
|> phases()
|> Enum.map(fn phase ->
path =
[subject_name, strategy.name, phase]
|> Enum.map(&to_string/1)
|> Path.join()
{"/#{path}", phase}
end)
end
@doc """
Handle HTTP requests.
"""
@spec plug(Password.t(), phase, Conn.t()) :: Conn.t()
def plug(strategy, :register, conn), do: Password.Plug.register(conn, strategy)
def plug(strategy, :sign_in, conn), do: Password.Plug.sign_in(conn, strategy)
def plug(strategy, :reset_request, conn), do: Password.Plug.reset_request(conn, strategy)
def plug(strategy, :reset, conn), do: Password.Plug.reset(conn, strategy)
@doc """
Perform actions.
"""
@spec action(Password.t(), phase, map) :: {:ok, Resource.record()} | {:error, any}
def action(strategy, :register, params), do: Password.Actions.register(strategy, params)
def action(strategy, :sign_in, params), do: Password.Actions.sign_in(strategy, params)
def action(strategy, :reset_request, params),
do: Password.Actions.reset_request(strategy, params)
def action(strategy, :reset, params), do: Password.Actions.reset(strategy, params)
end

View file

@ -0,0 +1,418 @@
defmodule AshAuthentication.Strategy.Password.Transformer do
@moduledoc """
DSL transformer for the password strategy.
Iterates through any password authentication strategies and ensures that all
the correct actions and settings are in place.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, HashProvider, Info, Sender, Strategy.Password}
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?(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
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Stream.filter(&is_struct(&1, Password))
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
with :ok <- validate_identity_field(strategy.identity_field, dsl_state),
:ok <- validate_hashed_password_field(strategy.hashed_password_field, dsl_state),
strategy <-
maybe_set_field_lazy(strategy, :register_action_name, &:"register_with_#{&1.name}"),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.register_action_name,
&build_register_action(&1, strategy)
),
:ok <- validate_register_action(dsl_state, strategy),
strategy <-
maybe_set_field_lazy(strategy, :sign_in_action_name, &:"sign_in_with_#{&1.name}"),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.sign_in_action_name,
&build_sign_in_action(&1, strategy)
),
:ok <- validate_sign_in_action(dsl_state, strategy),
:ok <- validate_behaviour(strategy.hash_provider, HashProvider),
{:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
%{strategy | resource: resource},
&(&1.name == strategy.name)
)
{:ok, dsl_state}
end
end
defp validate_identity_field(identity_field, dsl_state) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{: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]) do
validate_attribute_unique_constraint(dsl_state, [identity_field], resource)
end
end
defp validate_hashed_password_field(hashed_password_field, dsl_state) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, attribute} <- find_attribute(dsl_state, hashed_password_field),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
validate_attribute_option(attribute, resource, :sensitive?, [true])
end
end
defp build_register_action(_dsl_state, strategy) 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, strategy.password_field)
)
]
|> maybe_append(
strategy.confirmation_required?,
Transformer.build_entity!(
Resource.Dsl,
[:actions, :create],
:argument,
Keyword.put(password_opts, :name, strategy.password_confirmation_field)
)
)
changes =
[]
|> maybe_append(
strategy.confirmation_required?,
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate,
validation: Password.PasswordConfirmationValidation
)
)
|> Enum.concat([
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
change: Password.HashPasswordChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
change: GenerateTokenChange
)
])
Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: strategy.register_action_name,
arguments: arguments,
changes: changes,
allow_nil_input: [strategy.hashed_password_field]
)
end
defp validate_register_action(dsl_state, strategy) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name),
:ok <- validate_allow_nil_input(action, strategy.hashed_password_field),
:ok <- validate_password_argument(action, strategy.password_field, true),
:ok <-
validate_password_argument(
action,
strategy.password_confirmation_field,
strategy.confirmation_required?
),
:ok <- validate_action_has_change(action, Password.HashPasswordChange),
:ok <- validate_action_has_change(action, GenerateTokenChange) do
validate_action_has_validation(
action,
Password.PasswordConfirmationValidation,
strategy.confirmation_required?
)
end
end
defp 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
defp validate_password_argument(action, field, true) do
with :ok <- validate_action_argument_option(action, field, :type, [Ash.Type.String]) do
validate_action_argument_option(action, field, :sensitive?, [true])
end
end
defp validate_password_argument(_action, _field, _), do: :ok
defp validate_action_has_validation(action, validation, true),
do: validate_action_has_validation(action, validation)
defp validate_action_has_validation(_action, _validation, _), do: :ok
defp build_sign_in_action(dsl_state, strategy) do
identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.identity_field,
type: identity_attribute.type,
allow_nil?: false
),
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.password_field,
type: Type.String,
allow_nil?: false,
sensitive?: true
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: Password.SignInPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: strategy.sign_in_action_name,
arguments: arguments,
preparations: preparations,
get?: true
)
end
defp validate_sign_in_action(dsl_state, strategy) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name),
:ok <- validate_identity_argument(dsl_state, action, strategy.identity_field),
:ok <- validate_password_argument(action, strategy.password_field, true) do
validate_action_has_preparation(action, Password.SignInPreparation)
end
end
defp 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
defp maybe_transform_resettable(dsl_state, %{resettable: []} = strategy),
do: {:ok, dsl_state, strategy}
defp maybe_transform_resettable(dsl_state, %{resettable: [resettable]} = strategy) do
with {:ok, {sender, _opts}} <- Map.fetch(resettable, :sender),
:ok <- validate_behaviour(sender, Sender),
resettable <-
maybe_set_field_lazy(
resettable,
:request_password_reset_action_name,
fn _ -> :"request_password_reset_with_#{strategy.name}" end
),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
resettable.request_password_reset_action_name,
&build_reset_request_action(&1, resettable, strategy)
),
:ok <- validate_reset_request_action(dsl_state, resettable, strategy),
resettable <-
maybe_set_field_lazy(
resettable,
:password_reset_action_name,
fn _ -> :"password_reset_with_#{strategy.name}" end
),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
resettable.password_reset_action_name,
&build_reset_action(&1, resettable, strategy)
),
:ok <- validate_reset_action(dsl_state, resettable, strategy) do
{:ok, dsl_state, %{strategy | resettable: [resettable]}}
else
:error ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :password, :resettable],
message: "A `sender` is required."
)}
{:error, reason} ->
{:error, reason}
end
end
defp maybe_transform_resettable(_dsl_state, %{resettable: [_ | _]}),
do:
DslError.exception(
path: [:authentication, :strategies, :password],
message: "Only one `resettable` entity may be present."
)
defp build_reset_request_action(dsl_state, resettable, strategy) do
identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.identity_field,
type: identity_attribute.type,
allow_nil?: false
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: Password.RequestPasswordResetPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: resettable.request_password_reset_action_name,
arguments: arguments,
preparations: preparations
)
end
defp validate_reset_request_action(dsl_state, resettable, strategy) do
with {:ok, action} <-
validate_action_exists(dsl_state, resettable.request_password_reset_action_name),
:ok <- validate_identity_argument(dsl_state, action, strategy.identity_field) do
validate_action_has_preparation(action, Password.RequestPasswordResetPreparation)
end
end
defp build_reset_action(_dsl_state, resettable, strategy) do
password_opts = [
type: Type.String,
allow_nil?: false,
constraints: [min_length: 8],
sensitive?: true
]
arguments =
[
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
name: :reset_token,
type: Type.String,
sensitive?: true
),
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, strategy.password_field)
)
]
|> maybe_append(
strategy.confirmation_required?,
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, strategy.password_confirmation_field)
)
)
changes =
[
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: Password.ResetTokenValidation
)
]
|> maybe_append(
strategy.confirmation_required?,
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: Password.PasswordConfirmationValidation
)
)
|> Enum.concat([
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: Password.HashPasswordChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: GenerateTokenChange
)
])
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: resettable.password_reset_action_name,
arguments: arguments,
changes: changes,
accept: []
)
end
defp validate_reset_action(dsl_state, resettable, strategy) do
with {:ok, action} <-
validate_action_exists(dsl_state, resettable.password_reset_action_name),
:ok <- validate_action_has_validation(action, Password.ResetTokenValidation),
:ok <- validate_action_has_change(action, Password.HashPasswordChange),
:ok <- validate_password_argument(action, strategy.password_field, true),
:ok <-
validate_password_argument(
action,
strategy.password_confirmation_field,
strategy.confirmation_required?
),
:ok <-
validate_action_has_validation(
action,
Password.PasswordConfirmationValidation,
strategy.confirmation_required?
) do
validate_action_has_change(action, GenerateTokenChange)
end
end
end

View file

@ -0,0 +1,115 @@
defprotocol AshAuthentication.Strategy do
@moduledoc """
The protocol used for interacting with authentication strategies.
Any new Authentication strategy must implement this protocol.
"""
alias Ash.Resource
alias Plug.Conn
@typedoc "A path to match in web requests"
@type path :: String.t()
@typedoc """
The \"phase\" of the request.
Usually `:request` or `:callback` but can be any atom.
"""
@type phase :: atom
@typedoc """
The name of an individual action supported by the strategy.
This maybe not be the action name on the underlying resource, which may be
generated, but the name that the strategy itself calls the action.
"""
@type action :: atom
@typedoc """
An individual route.
Eg: `{"/user/password/sign_in", :sign_in}`
"""
@type route :: {path, phase}
@type http_method ::
:get | :head | :post | :put | :delete | :connect | :options | :trace | :patch
@doc """
Return a list of phases supported by the strategy.
## Example
iex> strategy = Info.strategy!(Example.User, :password)
...> phases(strategy)
[:register, :sign_in, :reset_request, :reset]
"""
@spec phases(t) :: [phase]
def phases(strategy)
@doc """
Return a list of actions supported by the strategy.
## Example
iex> strategy = Info.strategy!(Example.User, :password)
...> actions(strategy)
[:register, :sign_in, :reset_request, :reset]
"""
@spec actions(t) :: [action]
def actions(strategy)
@doc """
Used to build the routing table to route web requests to request phases for
each strategy.
## Example
iex> strategy = Info.strategy!(Example.User, :password)
...> routes(strategy)
[
{"/user/password/register", :register},
{"/user/password/sign_in", :sign_in},
{"/user/password/reset_request", :reset_request},
{"/user/password/reset", :reset}
]
"""
@spec routes(t) :: [route]
def routes(strategy)
@doc """
Return the HTTP method for a phase.
## Example
iex> strategy = Info.strategy!(Example.User, :oauth2)
...> method_for_phase(strategy, :request)
:get
"""
@spec method_for_phase(t, phase) :: http_method
def method_for_phase(t, phase)
@doc """
Handle requests routed to the strategy.
Each phase will be an atom (ie the second element in the route tuple).
See `phases/1` for a list of phases supported by the strategy.
"""
@spec plug(t, phase, Conn.t()) :: Conn.t()
def plug(strategy, phase, conn)
@doc """
Perform an named action.
Different strategies are likely to implement a number of different actions
depending on their configuration. Calling them via this function will ensure
that the context is correctly set, etc.
See `actions/1` for a list of actions provided by the strategy.
"""
@spec action(t, action, params :: map) :: :ok | {:ok, Resource.record()} | {:error, any}
def action(strategy, action_name, params)
end

View file

@ -18,9 +18,10 @@ defmodule AshAuthentication.TokenRevocation do
@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.
The token revocation resource is used to store the Json Web Token ID (jti) and
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
@ -75,10 +76,17 @@ defmodule AshAuthentication.TokenRevocation do
@doc """
Revoke a token.
## Example
iex> {token, _} = build_token()
...> revoke(Example.TokenRevocation, token)
:ok
"""
@spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any}
def revoke(resource, token) do
with {:ok, api} <- Info.api(resource) do
with {:ok, api} <- Info.revocation_api(resource) do
resource
|> Changeset.for_create(:revoke_token, %{token: token})
|> api.create(upsert?: true)
@ -92,10 +100,19 @@ defmodule AshAuthentication.TokenRevocation do
@doc """
Find out if (via it's JTI) a token has been revoked?
## Example
iex> {token, %{"jti" => jti}} = build_token()
...> revoked?(Example.TokenRevocation, jti)
false
...> revoke(Example.TokenRevocation, token)
...> revoked?(Example.TokenRevocation, jti)
true
"""
@spec revoked?(Resource.t(), jti :: String.t()) :: boolean
def revoked?(resource, jti) do
with {:ok, api} <- Info.api(resource) do
with {:ok, api} <- Info.revocation_api(resource) do
resource
|> Query.for_read(:revoked, %{jti: jti})
|> api.read()
@ -117,7 +134,7 @@ defmodule AshAuthentication.TokenRevocation do
## Note
Sadly this function iterates over all expired revocations and delete them
Sadly this function iterates over all expired revocations and deletes 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.
@ -130,7 +147,7 @@ defmodule AshAuthentication.TokenRevocation do
DataLayer.transaction(
resource,
fn ->
with {:ok, api} <- Info.api(resource),
with {:ok, api} <- Info.revocation_api(resource),
query <- Query.for_read(resource, :expired),
{:ok, expired} <- api.read(query) do
expired
@ -159,7 +176,7 @@ defmodule AshAuthentication.TokenRevocation do
"""
@spec remove_revocation(Resource.record()) :: :ok | {:error, any}
def remove_revocation(revocation) do
with {:ok, api} <- Info.api(revocation.__struct__) do
with {:ok, api} <- Info.revocation_api(revocation.__struct__) do
revocation
|> Changeset.for_destroy(:expire)
|> api.destroy()

View file

@ -2,7 +2,7 @@ defmodule AshAuthentication.TokenRevocation.Expunger do
@default_period_hrs 12
@moduledoc """
A genserver which periodically removes expired token revocations.
A `GenServer` which periodically removes expired token revocations.
Scans all token revocation resources every #{@default_period_hrs} hours and removes
any expired token revocations.

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.TokenRevocation.Info do
@moduledoc """
Generated configuration functions based on a resource's token DSL
configuration.
Introspection functions for the `AshAuthentication.TokenRevocation` Ash
extension.
"""
use AshAuthentication.InfoGenerator,

View file

@ -29,8 +29,8 @@ defmodule AshAuthentication.Transformer do
@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),
with {:ok, _api} <- validate_api_presence(dsl_state),
:ok <- validate_at_least_one_strategy(dsl_state),
{:ok, get_by_subject_action_name} <-
Info.authentication_get_by_subject_action_name(dsl_state),
{:ok, dsl_state} <-
@ -42,15 +42,8 @@ defmodule AshAuthentication.Transformer do
: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}
@ -109,7 +102,11 @@ defmodule AshAuthentication.Transformer do
end
defp validate_api_presence(dsl_state) do
case Transformer.get_option(dsl_state, [:authentication], :api) do
with api when not is_nil(api) <- Transformer.get_option(dsl_state, [:authentication], :api),
true <- function_exported?(api, :spark_is, 0),
Ash.Api <- api.spark_is() do
{:ok, api}
else
nil ->
{:error,
DslError.exception(
@ -117,25 +114,28 @@ defmodule AshAuthentication.Transformer do
message: "An API module must be present"
)}
api ->
{:ok, api}
_ ->
{:error,
DslError.exception(
path: [:authentication, :api],
message: "Module is not an Ash.Api."
)}
end
end
defp validate_at_least_one_authentication_provider(dsl_state) do
defp validate_at_least_one_strategy(dsl_state) do
ok? =
dsl_state
|> Transformer.get_persisted(:extensions, [])
|> Enum.any?(&Spark.implements_behaviour?(&1, AshAuthentication.Provider))
|> Transformer.get_entities([:authentication, :strategies])
|> Enum.any?()
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."
path: [:authentication, :strategies],
message: "Expected at least one authentication strategy"
)}
end

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.ProviderIdentity do
defmodule AshAuthentication.UserIdentity do
@dsl [
%Spark.Dsl.Section{
name: :provider_identity,
name: :user_identity,
describe: "Configure identity options for this resource",
schema: [
api: [
@ -19,10 +19,10 @@ defmodule AshAuthentication.ProviderIdentity do
doc: "The name of the `uid` attribute on this resource.",
default: :uid
],
provider_attribute_name: [
strategy_attribute_name: [
type: :atom,
doc: "The name of the `provider` attribute on this resource.",
default: :provider
doc: "The name of the `strategy` attribute on this resource.",
default: :strategy
],
user_id_attribute_name: [
type: :atom,
@ -69,21 +69,21 @@ defmodule AshAuthentication.ProviderIdentity do
]
@moduledoc """
An Ash extension which generates the default provider identities resource.
An Ash extension which generates the default user identities resource.
The provider identities resource is used to store information returned by
remote authentication providers (such as those provided by OAuth2) and maps
them to your user resource(s). This provides the following benefits:
The user identities resource is used to store information returned by remote
authentication strategies (such as those provided by OAuth2) and maps them to
your user resource(s). This provides the following benefits:
1. A user can be signed in to multiple authentication providers at once.
1. A user can be signed in to multiple authentication strategies at once.
2. For those provides which support it AshAuthentication can handle
automatic refreshing of tokens.
## Storage
Provider identities are expected to be relatively long-lived (although they're
User identities are expected to be relatively long-lived (although they're
deleted on log out), so should probably be stored using a permanent data layer
sush as `AshPostgres`.
sush as `ash_postgres`.
## Usage
@ -95,9 +95,9 @@ defmodule AshAuthentication.ProviderIdentity do
defmodule MyApp.Accounts.UserIdentity do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication.ProviderIdentity]
extensions: [AshAuthentication.UserIdentity]
provider_identity do
user_identity do
api MyApp.Accounts
user_resource MyApp.Accounts.User
end
@ -110,7 +110,7 @@ defmodule AshAuthentication.ProviderIdentity do
```
If you intend to operate with multiple user resources, you will need to define
multiple provider identity resources.
multiple user identity resources.
## Dsl
@ -125,5 +125,5 @@ defmodule AshAuthentication.ProviderIdentity do
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.ProviderIdentity.Transformer]
transformers: [AshAuthentication.UserIdentity.Transformer]
end

View file

@ -1,22 +1,23 @@
defmodule AshAuthentication.ProviderIdentity.Actions do
defmodule AshAuthentication.UserIdentity.Actions do
@moduledoc """
Code interface for provider identity actions.
Allows you to interact with ProviderIdentity resources without having to mess
Allows you to interact with UserIdentity resources without having to mess
around with changesets, apis, etc. These functions are delegated to from
within `AshAuthentication.ProviderIdentity`.
within `AshAuthentication.UserIdentity`.
"""
alias Ash.{Changeset, Resource}
alias AshAuthentication.ProviderIdentity
alias AshAuthentication.UserIdentity
@doc """
Upsert an identity for a user.
"""
@spec upsert(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
def upsert(resource, attributes) do
with {:ok, api} <- ProviderIdentity.Info.api(resource),
{:ok, upsert_action_name} <- ProviderIdentity.Info.upsert_action_name(resource),
with {:ok, api} <- UserIdentity.Info.user_identity_api(resource),
{:ok, upsert_action_name} <-
UserIdentity.Info.user_identity_upsert_action_name(resource),
action when is_map(action) <- Resource.Info.action(resource, upsert_action_name) do
resource
|> Changeset.for_create(upsert_action_name, attributes,

View file

@ -0,0 +1,10 @@
defmodule AshAuthentication.UserIdentity.Info do
@moduledoc """
Introspection functions for the `AshAuthentication.UserIdentity` Ash
extension.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.UserIdentity,
sections: [:user_identity]
end

View file

@ -1,13 +1,13 @@
defmodule AshAuthentication.ProviderIdentity.Transformer do
defmodule AshAuthentication.UserIdentity.Transformer do
@moduledoc """
The provider identity transformer.
The user identity transformer.
Sets up the default schema and actions for a provider identity resource.
Sets up the default schema and actions for a user identity resource.
"""
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.ProviderIdentity
alias AshAuthentication.UserIdentity
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
@ -44,25 +44,29 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
default: &Ash.UUID.generate/0
),
:ok <- validate_id_field(dsl_state, :id),
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
{:ok, access_token} <- ProviderIdentity.Info.access_token_attribute_name(dsl_state),
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
{:ok, strategy} <-
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
{:ok, user_id} <-
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
{:ok, access_token} <-
UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
{:ok, access_token_expires_at} <-
ProviderIdentity.Info.access_token_expires_at_attribute_name(dsl_state),
{:ok, refresh_token} <- ProviderIdentity.Info.refresh_token_attribute_name(dsl_state),
UserIdentity.Info.user_identity_access_token_expires_at_attribute_name(dsl_state),
{:ok, refresh_token} <-
UserIdentity.Info.user_identity_refresh_token_attribute_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_attribute(dsl_state, provider, Type.String,
maybe_build_attribute(dsl_state, strategy, Type.String,
allow_nil?: false,
writable?: true
),
:ok <- validate_provider_field(dsl_state, provider),
:ok <- validate_strategy_field(dsl_state, strategy),
{:ok, dsl_state} <-
maybe_build_attribute(dsl_state, uid, Type.String, allow_nil?: false, writable?: true),
:ok <- validate_uid_field(dsl_state, uid),
{:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, provider]),
{:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, strategy]),
:ok <-
validate_attribute_unique_constraint(dsl_state, [user_id, uid, provider], resource),
validate_attribute_unique_constraint(dsl_state, [user_id, uid, strategy], resource),
{:ok, dsl_state} <-
maybe_build_attribute(dsl_state, access_token, Type.String,
allow_nil?: true,
@ -81,8 +85,9 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
writable?: true
),
:ok <- validate_token_field(dsl_state, refresh_token),
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
{:ok, user_relationship} <- ProviderIdentity.Info.user_relationship_name(dsl_state),
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
{:ok, user_relationship} <-
UserIdentity.Info.user_identity_user_relationship_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_relationship(
dsl_state,
@ -90,11 +95,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
&build_user_relationship(&1, user_relationship, user_resource)
),
:ok <- validate_user_relationship(dsl_state, user_relationship, user_resource),
{:ok, upsert_action} <- ProviderIdentity.Info.upsert_action_name(dsl_state),
{:ok, upsert_action} <-
UserIdentity.Info.user_identity_upsert_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(dsl_state, upsert_action, &build_upsert_action(&1, upsert_action)),
:ok <- validate_upsert_action(dsl_state, upsert_action),
{:ok, destroy_action} <- ProviderIdentity.Info.destroy_action_name(dsl_state),
{:ok, destroy_action} <-
UserIdentity.Info.user_identity_destroy_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
@ -103,7 +110,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
),
:ok <-
validate_destroy_action(dsl_state, destroy_action),
{:ok, read_action} <- ProviderIdentity.Info.read_action_name(dsl_state),
{:ok, read_action} <-
UserIdentity.Info.user_identity_read_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(dsl_state, read_action, &build_read_action(&1, read_action)),
:ok <- validate_read_action(dsl_state, read_action) do
@ -112,11 +120,11 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
end
defp validate_api_presence(dsl_state) do
case Transformer.get_option(dsl_state, [:provider_identity], :api) do
case Transformer.get_option(dsl_state, [:user_identity], :api) do
nil ->
{:error,
DslError.exception(
path: [:provider_identity, :api],
path: [:user_identity, :api],
message: "An API module must be present"
)}
@ -135,7 +143,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
end
end
defp validate_provider_field(dsl_state, field_name) do
defp validate_strategy_field(dsl_state, field_name) do
with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, attribute} <- find_attribute(dsl_state, field_name),
:ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]),
@ -190,7 +198,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
defp build_user_relationship(dsl_state, name, destination) do
with {:ok, id_attr} <- find_pk(destination),
{:ok, api} <- AshAuthentication.Info.authentication_api(destination),
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state) do
{:ok, user_id} <-
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state) do
Transformer.build_entity(Resource.Dsl, [:relationships], :belongs_to,
name: name,
destination: destination,
@ -209,7 +218,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
with {:ok, id_attr} <- find_pk(destination),
{:ok, api} <- AshAuthentication.Info.authentication_api(destination),
{:ok, relationship} <- find_relationship(dsl_state, name),
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
{:ok, user_id} <-
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
:ok <- validate_field_in_values(relationship, :destination, [destination]),
:ok <- validate_field_in_values(relationship, :destination_attribute, [id_attr.name]),
:ok <- validate_field_in_values(relationship, :source_attribute, [user_id]),
@ -219,11 +229,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
end
defp build_upsert_action(dsl_state, action_name) do
with {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
{:ok, identity} <- find_identity(dsl_state, [user_id, uid, provider]),
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
with {:ok, user_id} <-
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
{:ok, strategy} <-
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
{:ok, identity} <- find_identity(dsl_state, [user_id, uid, strategy]),
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
{:ok, user_resource_id} <- find_pk(user_resource) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
@ -245,7 +257,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
changes = [
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
change: ProviderIdentity.UpsertIdentityChange
change: UserIdentity.UpsertIdentityChange
)
]
@ -255,7 +267,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
upsert_identity: identity.name,
arguments: arguments,
changes: changes,
accept: [provider]
accept: [strategy]
)
end
end
@ -266,22 +278,24 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
:ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :type, [:map, Type.Map]),
:ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]),
:ok <- validate_action_has_change(action, ProviderIdentity.UpsertIdentityChange),
:ok <- validate_action_has_change(action, UserIdentity.UpsertIdentityChange),
:ok <- validate_field_in_values(action, :type, [:create]),
:ok <- validate_field_in_values(action, :upsert?, [true]),
{:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state),
{:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state),
{:ok, user_id} <-
UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
{:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
{:ok, user_resource_id} <- find_pk(user_resource),
:ok <- validate_action_argument_option(action, user_id, :type, [user_resource_id.type]),
:ok <- validate_action_argument_option(action, user_id, :allow_nil?, [false]),
{:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state),
{:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state),
{:ok, identity} <- find_identity(dsl_state, [uid, user_id, provider]),
{:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
{:ok, strategy} <-
UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
{:ok, identity} <- find_identity(dsl_state, [uid, user_id, strategy]),
:ok <- validate_field_in_values(action, :upsert_identity, [identity.name]) do
:ok
else
{:error, reason} when is_binary(reason) ->
{:error, DslError.exception(path: [:provider_identity], message: reason)}
{:error, DslError.exception(path: [:user_identity], message: reason)}
{:error, reason} ->
{:error, reason}
@ -289,7 +303,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do
:error ->
{:error,
DslError.exception(
path: [:provider_identity],
path: [:user_identity],
message: "Configuration error while validating upsert action."
)}
end

View file

@ -1,6 +1,6 @@
defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do
defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
@moduledoc """
A change which upserts a user's identity into the identity provider resource.
A change which upserts a user's identity into the user identity resource.
Expects the following arguments:
@ -9,18 +9,21 @@ defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do
- `oauth_tokens` a map with string keys containing the OAuth2 token
response.
- `user_id` the ID of the user this identity relates to.
- `provider` the name of the provider.
- `strategy` the name of the strategy.
This is usually dynamically inserted into a generated action, however you can
add it to your own action if needed.
"""
use Ash.Resource.Change
alias Ash.{Changeset, Resource.Change}
alias AshAuthentication.ProviderIdentity.Info
alias AshAuthentication.UserIdentity.Info
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
cfg = Info.options(changeset.resource)
cfg = Info.user_identity_options(changeset.resource)
user_info = Changeset.get_argument(changeset, :user_info)
oauth_tokens = Changeset.get_argument(changeset, :oauth_tokens)

View file

@ -130,4 +130,22 @@ defmodule AshAuthentication.Utils do
relationship -> {:ok, relationship}
end
end
@doc """
Optionally set a field in a map.
Like `Map.put_new/3` except that it overwrites fields if their contents are
falsy.
"""
@spec maybe_set_field(map, any, any) :: map
def maybe_set_field(map, field, value) when is_falsy(:erlang.map_get(field, map)),
do: Map.put(map, field, value)
def maybe_set_field(map, _field, _value), do: map
def maybe_set_field_lazy(map, field, generator)
when is_falsy(:erlang.map_get(field, map)) and is_function(generator, 1),
do: Map.put(map, field, generator.(map))
def maybe_set_field_lazy(map, _field, _generator), do: map
end

View file

@ -113,7 +113,7 @@ defmodule AshAuthentication.Validations do
"""
@spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()}
def validate_token_generation_enabled(dsl_state) do
if AshAuthentication.Info.tokens_enabled?(dsl_state),
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
do: :ok,
else:
{:error,
@ -156,4 +156,18 @@ defmodule AshAuthentication.Validations do
"The `#{inspect(extension)}` extension must also be present on this resource for password authentication to work."
)}
end
@doc """
Build an attribute if not present.
"""
@spec maybe_build_attribute(Dsl.t(), atom, (Dsl.t() -> {:ok, Attribute.t()})) :: {:ok, Dsl.t()}
def maybe_build_attribute(dsl_state, attribute_name, builder) do
with {:error, _} <- find_attribute(dsl_state, attribute_name),
{:ok, attribute} <- builder.(dsl_state) do
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
else
{:ok, attribute} when is_struct(attribute, Attribute) -> {:ok, dsl_state}
{:error, reason} -> {:error, reason}
end
end
end

20
mix.exs
View file

@ -30,8 +30,13 @@ defmodule AshAuthentication.MixProject do
groups_for_modules: [
Extensions: [
AshAuthentication,
AshAuthentication.PasswordAuthentication,
AshAuthentication.TokenRevocation
AshAuthentication.TokenRevocation,
AshAuthentication.UserIdentity
],
Strategies: [
AshAuthentication.Strategy,
AshAuthentication.Strategy.Password,
AshAuthentication.Strategy.OAuth2
],
Cryptography: [
AshAuthentication.HashProvider,
@ -39,9 +44,7 @@ defmodule AshAuthentication.MixProject do
AshAuthentication.Jwt,
AshAuthentication.Jwt.Config
],
"Password Authentication": ~r/^AshAuthentication\.PasswordAuthentication.*/,
Plug: ~r/^AshAuthentication\.Plug.*/,
"Token Revocation": ~r/^AshAuthentication\.TokenRevocation.*/,
Internals: ~r/^AshAuthentication.*/
]
]
@ -51,7 +54,8 @@ defmodule AshAuthentication.MixProject do
def package do
[
maintainers: [
"James Harton <james.harton@alembic.com.au>"
"James Harton <james.harton@alembic.com.au>",
"Zach Daniel <zach@zachdaniel.dev>"
],
licenses: ["MIT"],
links: %{
@ -78,6 +82,7 @@ defmodule AshAuthentication.MixProject do
defp deps do
[
{:ash, "~> 2.4"},
{:spark, "~> 0.2.12"},
{:jason, "~> 1.4"},
{:joken, "~> 2.5"},
{:plug, "~> 1.13"},
@ -86,9 +91,8 @@ defmodule AshAuthentication.MixProject do
{:castore, "~> 0.1"},
{:bcrypt_elixir, "~> 3.0"},
{:absinthe_plug, "~> 1.5", only: [:dev, :test]},
# These two can be changed back to hex once the next release goes out.
{:ash_graphql, github: "ash-project/ash_graphql", only: [:dev, :test]},
{:ash_json_api, github: "ash-project/ash_json_api", only: [:dev, :test]},
{:ash_graphql, "~> 0.21", only: [:dev, :test]},
{:ash_json_api, "~> 0.30", only: [:dev, :test]},
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},

View file

@ -1,10 +1,10 @@
%{
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"ash": {:hex, :ash, "2.4.10", "c3d17521515b05559ef1a592b421a6120d15679c07e84df4cd80b8df08088542", [: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]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.2 and >= 0.2.10", [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", "e31630ccb9a42f092bc5ca0181d7cf34adef5a48df923ea42dc4256822bbbd89"},
"ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []},
"ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []},
"ash_postgres": {:hex, :ash_postgres, "1.1.2", "1afd8ac43e68de8a92d22c8e8f3c36552665bc1c91dcdc4e5945d4e88c606bbe", [: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", "0877e0d5e7ff36b7c6b0f22ce95ed8980695a0ba309cba77c60cf911c9678854"},
"ash": {:hex, :ash, "2.4.2", "ba579e6654c32b1da49f17938d2f1445066f27e61eedbf0fae431b816b49d1be", [: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.2.0", [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", "da8f94a19cf29617526ca2b1a75f6fae804c1db7c825b49982c603f503a615bd"},
"ash_graphql": {:hex, :ash_graphql, "0.21.0", "b22b7786895552ef7bd4082815da5d895529dc9f81606a4a195f5790d163ebde", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 2.4.0", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8b71047d86d7c279d4b6e185a33239a0a06486a0d574e9cc58628234e07aa5fc"},
"ash_json_api": {:hex, :ash_json_api, "0.30.1", "54e60c4862eee35ed8a9a925e5c99be2b80e36a2507355bdb0f0974defe82a8d", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b8b4827aa02de75a9a48d2941e813947da46b7dbfdd84cd20959dbaec103f830"},
"ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [: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", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"},
"assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"},
"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"},
@ -24,13 +24,13 @@
"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.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"},
"ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [: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", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"},
"ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [: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", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"},
"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", "ef2401be49e8471abb13ad1805067231973fecca", []},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
"ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [: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", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"},
"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"},
"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"},

Some files were not shown because too many files have changed in this diff Show more