mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 21:03:23 +12:00
feat: Add new "magic link" authentication strategy. (#184)
This commit is contained in:
parent
533872723e
commit
cf3d227ef2
23 changed files with 845 additions and 26 deletions
|
@ -199,6 +199,50 @@ defmodule DevServer.TestPage do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_strategy(strategy, phase, options)
|
||||||
|
when is_struct(strategy, Strategy.MagicLink) and phase == :request do
|
||||||
|
EEx.eval_string(
|
||||||
|
~s"""
|
||||||
|
<form method="<%= @method %>" action="<%= @route %>">
|
||||||
|
<fieldset>
|
||||||
|
<legend><%= @strategy.name %> request</legend>
|
||||||
|
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
|
||||||
|
<br />
|
||||||
|
<input type="submit" value="Request" />
|
||||||
|
</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 is_struct(strategy, Strategy.MagicLink) and phase == :sign_in do
|
||||||
|
EEx.eval_string(
|
||||||
|
~s"""
|
||||||
|
<form method="<%= @method %>" action="<%= @route %>">
|
||||||
|
<fieldset>
|
||||||
|
<legend><%= @strategy.name %> sign in</legend>
|
||||||
|
<input type="text" name="token" placeholder="token" />
|
||||||
|
<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) do
|
defp render_strategy(strategy, phase, _options) do
|
||||||
inspect({strategy, phase})
|
inspect({strategy, phase})
|
||||||
end
|
end
|
||||||
|
|
|
@ -108,6 +108,7 @@ defmodule AshAuthentication do
|
||||||
Info,
|
Info,
|
||||||
Strategy.Auth0,
|
Strategy.Auth0,
|
||||||
Strategy.Github,
|
Strategy.Github,
|
||||||
|
Strategy.MagicLink,
|
||||||
Strategy.OAuth2,
|
Strategy.OAuth2,
|
||||||
Strategy.Password
|
Strategy.Password
|
||||||
}
|
}
|
||||||
|
@ -117,7 +118,10 @@ defmodule AshAuthentication do
|
||||||
use Spark.Dsl.Extension,
|
use Spark.Dsl.Extension,
|
||||||
sections: dsl(),
|
sections: dsl(),
|
||||||
dsl_patches:
|
dsl_patches:
|
||||||
Enum.flat_map([Confirmation, Auth0, Github, OAuth2, Password], & &1.dsl_patches()),
|
Enum.flat_map(
|
||||||
|
[Confirmation, Auth0, Github, OAuth2, Password, MagicLink],
|
||||||
|
& &1.dsl_patches()
|
||||||
|
),
|
||||||
transformers: [
|
transformers: [
|
||||||
AshAuthentication.Transformer,
|
AshAuthentication.Transformer,
|
||||||
AshAuthentication.Strategy.Custom.Transformer
|
AshAuthentication.Strategy.Custom.Transformer
|
||||||
|
|
|
@ -18,7 +18,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
|
||||||
@spec transform(Confirmation.t(), map) ::
|
@spec transform(Confirmation.t(), map) ::
|
||||||
{:ok, Confirmation.t() | map} | {:error, Exception.t()}
|
{:ok, Confirmation.t() | map} | {:error, Exception.t()}
|
||||||
def transform(strategy, dsl_state) do
|
def transform(strategy, dsl_state) do
|
||||||
with :ok <- validate_token_generation_enabled(dsl_state),
|
with :ok <-
|
||||||
|
validate_token_generation_enabled(
|
||||||
|
dsl_state,
|
||||||
|
"Token generation must be enabled for password resets to work."
|
||||||
|
),
|
||||||
:ok <- validate_monitor_fields(dsl_state, strategy),
|
:ok <- validate_monitor_fields(dsl_state, strategy),
|
||||||
{:ok, dsl_state} <-
|
{:ok, dsl_state} <-
|
||||||
maybe_build_action(
|
maybe_build_action(
|
||||||
|
|
|
@ -89,6 +89,8 @@ defmodule AshAuthentication.Jwt do
|
||||||
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
|
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
|
||||||
resource = user.__struct__
|
resource = user.__struct__
|
||||||
|
|
||||||
|
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
|
||||||
|
|
||||||
default_claims = Config.default_claims(resource, opts)
|
default_claims = Config.default_claims(resource, opts)
|
||||||
signer = Config.token_signer(resource, opts)
|
signer = Config.token_signer(resource, opts)
|
||||||
|
|
||||||
|
@ -105,21 +107,21 @@ defmodule AshAuthentication.Jwt do
|
||||||
end
|
end
|
||||||
|
|
||||||
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
|
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
|
||||||
:ok <- maybe_store_token(token, resource, user) do
|
:ok <- maybe_store_token(token, resource, user, purpose) do
|
||||||
{:ok, token, claims}
|
{:ok, token, claims}
|
||||||
else
|
else
|
||||||
{:error, _reason} -> :error
|
{:error, _reason} -> :error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_store_token(token, resource, user) do
|
defp maybe_store_token(token, resource, user, purpose) do
|
||||||
if Info.authentication_tokens_store_all_tokens?(resource) do
|
if Info.authentication_tokens_store_all_tokens?(resource) do
|
||||||
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
|
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
|
||||||
TokenResource.Actions.store_token(
|
TokenResource.Actions.store_token(
|
||||||
token_resource,
|
token_resource,
|
||||||
%{
|
%{
|
||||||
"token" => token,
|
"token" => token,
|
||||||
"purpose" => "user"
|
"purpose" => to_string(purpose)
|
||||||
},
|
},
|
||||||
context: %{
|
context: %{
|
||||||
ash_authentication: %{
|
ash_authentication: %{
|
||||||
|
|
|
@ -84,7 +84,10 @@ defmodule AshAuthentication.Strategy.Custom do
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
raise CompileError,
|
raise CompileError,
|
||||||
"You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`."
|
file: __ENV__.file,
|
||||||
|
line: __ENV__.line,
|
||||||
|
description:
|
||||||
|
"You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`."
|
||||||
end
|
end
|
||||||
|
|
||||||
use Spark.Dsl.Extension,
|
use Spark.Dsl.Extension,
|
||||||
|
|
149
lib/ash_authentication/strategies/magic_link.ex
Normal file
149
lib/ash_authentication/strategies/magic_link.ex
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink do
|
||||||
|
alias __MODULE__.{Dsl, Transformer, Verifier}
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
Strategy for authentication using a magic link.
|
||||||
|
|
||||||
|
In order to use magic link 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. Have tokens enabled.
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
authentication do
|
||||||
|
api MyApp.Accounts
|
||||||
|
|
||||||
|
strategies do
|
||||||
|
magic_link do
|
||||||
|
identity_field :email
|
||||||
|
sender fn user, token, _opts ->
|
||||||
|
MyApp.Emails.deliver_magic_link(user, token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_email, [:email]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
By default the magic link strategy will automatically generate the request and
|
||||||
|
sign-in 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
|
||||||
|
|
||||||
|
Requesting that a magic link token is sent for a user:
|
||||||
|
|
||||||
|
iex> strategy = Info.strategy!(Example.User, :magic_link)
|
||||||
|
...> user = build_user()
|
||||||
|
...> Strategy.action(strategy, :request, %{"username" => user.username})
|
||||||
|
:ok
|
||||||
|
|
||||||
|
Signing in using a magic link token:
|
||||||
|
|
||||||
|
...> {:ok, token} = MagicLink.request_token_for(strategy, user)
|
||||||
|
...> {:ok, signed_in_user} = Strategy.action(strategy, :sign_in, %{"token" => token})
|
||||||
|
...> signed_in_user.id == user
|
||||||
|
true
|
||||||
|
|
||||||
|
## Plugs
|
||||||
|
|
||||||
|
The magic link strategy provides plug endpoints for both request and sign-in
|
||||||
|
actions.
|
||||||
|
|
||||||
|
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, :magic_link)
|
||||||
|
...> user = build_user()
|
||||||
|
...> conn = conn(:post, "/user/magic_link/request", %{"user" => %{"username" => user.username}})
|
||||||
|
...> conn = Strategy.plug(strategy, :request, conn)
|
||||||
|
...> {_conn, {:ok, nil}} = Plug.Helpers.get_authentication_result(conn)
|
||||||
|
|
||||||
|
...> {:ok, token} = MagicLink.request_token_for(strategy, user)
|
||||||
|
...> conn = conn(:get, "/user/magic_link", %{"token" => token})
|
||||||
|
...> conn = Strategy.plug(strategy, :sign_in, conn)
|
||||||
|
...> {_conn, {:ok, signed_in_user}} = Plug.Helpers.get_authentication_result(conn)
|
||||||
|
...> signed_in_user.id == user.id
|
||||||
|
true
|
||||||
|
|
||||||
|
## DSL Documentation
|
||||||
|
|
||||||
|
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
|
||||||
|
"""
|
||||||
|
|
||||||
|
defstruct identity_field: :username,
|
||||||
|
name: nil,
|
||||||
|
request_action_name: nil,
|
||||||
|
resource: nil,
|
||||||
|
sender: nil,
|
||||||
|
sign_in_action_name: nil,
|
||||||
|
single_use_token?: true,
|
||||||
|
token_lifetime: 10,
|
||||||
|
token_param_name: :token
|
||||||
|
|
||||||
|
use AshAuthentication.Strategy.Custom, entity: Dsl.dsl()
|
||||||
|
|
||||||
|
alias Ash.Resource
|
||||||
|
alias AshAuthentication.Jwt
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
identity_field: atom,
|
||||||
|
name: atom,
|
||||||
|
request_action_name: atom,
|
||||||
|
resource: module,
|
||||||
|
sender: {module, keyword},
|
||||||
|
single_use_token?: boolean,
|
||||||
|
sign_in_action_name: atom,
|
||||||
|
token_lifetime: pos_integer(),
|
||||||
|
token_param_name: atom
|
||||||
|
}
|
||||||
|
|
||||||
|
defdelegate transform(strategy, dsl_state), to: Transformer
|
||||||
|
defdelegate verify(strategy, dsl_state), to: Verifier
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generate a magic link token for a user.
|
||||||
|
|
||||||
|
Used by `AshAuthentication.Strategy.MagicLink.RequestPreparation`.
|
||||||
|
"""
|
||||||
|
@spec request_token_for(t, Resource.record()) :: {:ok, binary} | :error
|
||||||
|
def request_token_for(strategy, user)
|
||||||
|
when is_struct(strategy, __MODULE__) and is_struct(user, strategy.resource) do
|
||||||
|
case Jwt.token_for_user(user, %{"act" => strategy.sign_in_action_name},
|
||||||
|
token_lifetime: strategy.token_lifetime * 60,
|
||||||
|
purpose: :magic_link
|
||||||
|
) do
|
||||||
|
{:ok, token, _claims} -> {:ok, token}
|
||||||
|
:error -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
84
lib/ash_authentication/strategies/magic_link/actions.ex
Normal file
84
lib/ash_authentication/strategies/magic_link/actions.ex
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.Actions do
|
||||||
|
@moduledoc """
|
||||||
|
Actions for the magic link strategy.
|
||||||
|
|
||||||
|
Provides the code interface for working with user resources for providing
|
||||||
|
magic links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Ash.{Query, Resource}
|
||||||
|
alias AshAuthentication.{Errors, Info, Strategy.MagicLink}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Request a magic link for a user.
|
||||||
|
"""
|
||||||
|
@spec request(MagicLink.t(), map, keyword) :: :ok | {:error, any}
|
||||||
|
def request(strategy, params, options) do
|
||||||
|
api = Info.authentication_api!(strategy.resource)
|
||||||
|
|
||||||
|
strategy.resource
|
||||||
|
|> Query.new()
|
||||||
|
|> Query.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|> Query.for_read(strategy.request_action_name, params)
|
||||||
|
|> api.read(options)
|
||||||
|
|> case do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempt to sign a user in via magic link.
|
||||||
|
"""
|
||||||
|
@spec sign_in(MagicLink.t(), map, keyword) ::
|
||||||
|
{:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
|
||||||
|
def sign_in(strategy, params, options) do
|
||||||
|
api = Info.authentication_api!(strategy.resource)
|
||||||
|
|
||||||
|
strategy.resource
|
||||||
|
|> Query.new()
|
||||||
|
|> Query.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|> Query.for_read(strategy.sign_in_action_name, params)
|
||||||
|
|> api.read(options)
|
||||||
|
|> case do
|
||||||
|
{:ok, [user]} ->
|
||||||
|
{:ok, user}
|
||||||
|
|
||||||
|
{:ok, []} ->
|
||||||
|
{:error,
|
||||||
|
Errors.AuthenticationFailed.exception(
|
||||||
|
caused_by: %{
|
||||||
|
module: __MODULE__,
|
||||||
|
strategy: strategy,
|
||||||
|
action: :sign_in,
|
||||||
|
message: "Query returned no users"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:ok, _users} ->
|
||||||
|
{:error,
|
||||||
|
Errors.AuthenticationFailed.exception(
|
||||||
|
caused_by: %{
|
||||||
|
module: __MODULE__,
|
||||||
|
strategy: strategy,
|
||||||
|
action: :sign_in,
|
||||||
|
message: "Query returned too many users"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} when is_exception(error) ->
|
||||||
|
{:error, Errors.AuthenticationFailed.exception(caused_by: error)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error,
|
||||||
|
Errors.AuthenticationFailed.exception(
|
||||||
|
caused_by: %{
|
||||||
|
module: __MODULE__,
|
||||||
|
strategy: strategy,
|
||||||
|
action: :sign_in,
|
||||||
|
message: "Query returned error: #{inspect(error)}"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
91
lib/ash_authentication/strategies/magic_link/dsl.ex
Normal file
91
lib/ash_authentication/strategies/magic_link/dsl.ex
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.Dsl do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias AshAuthentication.Strategy.{Custom, MagicLink}
|
||||||
|
alias Spark.Dsl.Entity
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec dsl :: Custom.entity()
|
||||||
|
def dsl do
|
||||||
|
%Entity{
|
||||||
|
name: :magic_link,
|
||||||
|
describe: "Strategy for authenticating using local users with a magic link",
|
||||||
|
args: [{:optional, :name, :magic_link}],
|
||||||
|
hide: [:name],
|
||||||
|
target: MagicLink,
|
||||||
|
schema: [
|
||||||
|
name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: "Uniquely identifies the strategy.",
|
||||||
|
required: true
|
||||||
|
],
|
||||||
|
identity_field: [
|
||||||
|
type: :atom,
|
||||||
|
doc: """
|
||||||
|
The name of the attribute which uniquely identifies the user.
|
||||||
|
|
||||||
|
Usually something like `username` or `email_address`.
|
||||||
|
""",
|
||||||
|
default: :username
|
||||||
|
],
|
||||||
|
token_lifetime: [
|
||||||
|
type: :pos_integer,
|
||||||
|
doc: """
|
||||||
|
How long the sign in token is valid, in minutes.
|
||||||
|
""",
|
||||||
|
default: 10
|
||||||
|
],
|
||||||
|
request_action_name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: """
|
||||||
|
The name to use for the request action.
|
||||||
|
|
||||||
|
If not present it will be generated by prepending the strategy name
|
||||||
|
with `request_`.
|
||||||
|
""",
|
||||||
|
required: false
|
||||||
|
],
|
||||||
|
single_use_token?: [
|
||||||
|
type: :boolean,
|
||||||
|
doc: """
|
||||||
|
Automatically revoke the token once it's been used for sign in.
|
||||||
|
""",
|
||||||
|
default: true
|
||||||
|
],
|
||||||
|
sign_in_action_name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: """
|
||||||
|
The name to use for the sign in action.
|
||||||
|
|
||||||
|
If not present it will be generated by prepending the strategy name
|
||||||
|
with `sign_in_with_`.
|
||||||
|
""",
|
||||||
|
required: false
|
||||||
|
],
|
||||||
|
token_param_name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: """
|
||||||
|
The name of the token parameter in the incoming sign-in request.
|
||||||
|
""",
|
||||||
|
default: :token,
|
||||||
|
required: false
|
||||||
|
],
|
||||||
|
sender: [
|
||||||
|
type:
|
||||||
|
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||||
|
{AshAuthentication.SenderFunction, 3}},
|
||||||
|
doc: """
|
||||||
|
How to send the magic link to the user.
|
||||||
|
|
||||||
|
Allows you to glue sending of magic links 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
|
61
lib/ash_authentication/strategies/magic_link/plug.ex
Normal file
61
lib/ash_authentication/strategies/magic_link/plug.ex
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.Plug do
|
||||||
|
@moduledoc """
|
||||||
|
Plugs for the magic link strategy.
|
||||||
|
|
||||||
|
Handles requests and sign-ins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias AshAuthentication.{Info, Strategy, Strategy.MagicLink}
|
||||||
|
alias Plug.Conn
|
||||||
|
import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1]
|
||||||
|
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handle a request for a magic link.
|
||||||
|
|
||||||
|
Retrieves form parameters from nested within the subject name, eg:
|
||||||
|
|
||||||
|
```
|
||||||
|
%{
|
||||||
|
"user" => %{
|
||||||
|
"email" => "marty@mcfly.me"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
@spec request(Conn.t(), MagicLink.t()) :: Conn.t()
|
||||||
|
def request(conn, strategy) do
|
||||||
|
params = subject_params(conn, strategy)
|
||||||
|
opts = opts(conn)
|
||||||
|
result = Strategy.action(strategy, :request, params, opts)
|
||||||
|
store_authentication_result(conn, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sign in via magic link.
|
||||||
|
"""
|
||||||
|
@spec sign_in(Conn.t(), MagicLink.t()) :: Conn.t()
|
||||||
|
def sign_in(conn, strategy) do
|
||||||
|
params =
|
||||||
|
conn.params
|
||||||
|
|> Map.take([to_string(strategy.token_param_name)])
|
||||||
|
|
||||||
|
opts = opts(conn)
|
||||||
|
result = Strategy.action(strategy, :sign_in, params, opts)
|
||||||
|
store_authentication_result(conn, 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
|
||||||
|
|
||||||
|
defp opts(conn) do
|
||||||
|
[actor: get_actor(conn), tenant: get_tenant(conn)]
|
||||||
|
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,43 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.RequestPreparation do
|
||||||
|
@moduledoc """
|
||||||
|
Prepare a query for a magic link request.
|
||||||
|
|
||||||
|
This preparation performs three jobs, one before the query executes and two
|
||||||
|
after:
|
||||||
|
1. it constraints the query to match the identity field passed to the action.
|
||||||
|
2. if there is a user returned by the query, then
|
||||||
|
a. generate a magic link token and
|
||||||
|
b. publish a notification.
|
||||||
|
|
||||||
|
Always returns an empty result.
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Preparation
|
||||||
|
alias Ash.{Query, Resource.Preparation}
|
||||||
|
alias AshAuthentication.{Info, Strategy.MagicLink}
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||||
|
def prepare(query, _opts, _context) do
|
||||||
|
strategy = Info.strategy_for_action!(query.resource, query.action.name)
|
||||||
|
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_action(_query, [user], %{sender: {sender, send_opts}} = strategy) do
|
||||||
|
case MagicLink.request_token_for(strategy, user) do
|
||||||
|
{:ok, token} -> sender.send(user, token, send_opts)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, []}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_action(_, _, _), do: {:ok, []}
|
||||||
|
end
|
|
@ -0,0 +1,51 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.SignInPreparation do
|
||||||
|
@moduledoc """
|
||||||
|
Prepare a query for sign in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ash.Resource.Preparation
|
||||||
|
alias AshAuthentication.{Info, Jwt, TokenResource}
|
||||||
|
alias Ash.{Query, Resource, Resource.Preparation}
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||||
|
def prepare(query, _otps, _context) do
|
||||||
|
subject_name =
|
||||||
|
query.resource
|
||||||
|
|> Info.authentication_subject_name!()
|
||||||
|
|> to_string()
|
||||||
|
|
||||||
|
with {:ok, strategy} <- Info.strategy_for_action(query.resource, query.action.name),
|
||||||
|
token when is_binary(token) <- Query.get_argument(query, strategy.token_param_name),
|
||||||
|
{:ok, %{"act" => token_action, "sub" => subject}, _} <-
|
||||||
|
Jwt.verify(token, query.resource),
|
||||||
|
^token_action <- to_string(strategy.sign_in_action_name),
|
||||||
|
%URI{path: ^subject_name, query: primary_key} <- URI.parse(subject) do
|
||||||
|
primary_key =
|
||||||
|
primary_key
|
||||||
|
|> URI.decode_query()
|
||||||
|
|> Enum.to_list()
|
||||||
|
|
||||||
|
query
|
||||||
|
|> Query.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|> Query.filter(^primary_key)
|
||||||
|
|> Query.after_action(fn
|
||||||
|
query, [record] ->
|
||||||
|
if strategy.single_use_token? do
|
||||||
|
token_resource = Info.authentication_tokens_token_resource!(query.resource)
|
||||||
|
:ok = TokenResource.revoke(token_resource, token)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, token, _claims} = Jwt.token_for_user(record)
|
||||||
|
{:ok, [Resource.put_metadata(record, :token, token)]}
|
||||||
|
|
||||||
|
_query, [] ->
|
||||||
|
{:ok, []}
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
_ -> Query.limit(query, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
48
lib/ash_authentication/strategies/magic_link/strategy.ex
Normal file
48
lib/ash_authentication/strategies/magic_link/strategy.ex
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.MagicLink do
|
||||||
|
@moduledoc false
|
||||||
|
alias Ash.Resource
|
||||||
|
alias AshAuthentication.{Info, Strategy, Strategy.MagicLink}
|
||||||
|
alias Plug.Conn
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec name(MagicLink.t()) :: atom
|
||||||
|
def name(strategy), do: strategy.name
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec phases(MagicLink.t()) :: [Strategy.phase()]
|
||||||
|
def phases(_strategy), do: [:request, :sign_in]
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec actions(MagicLink.t()) :: [Strategy.action()]
|
||||||
|
def actions(_strategy), do: [:request, :sign_in]
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec method_for_phase(MagicLink.t(), atom) :: Strategy.http_method()
|
||||||
|
def method_for_phase(_strategy, :request), do: :post
|
||||||
|
def method_for_phase(_strategy, :sign_in), do: :get
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec routes(MagicLink.t()) :: [Strategy.route()]
|
||||||
|
def routes(strategy) do
|
||||||
|
subject_name = Info.authentication_subject_name!(strategy.resource)
|
||||||
|
|
||||||
|
[
|
||||||
|
{"/#{subject_name}/#{strategy.name}/request", :request},
|
||||||
|
{"/#{subject_name}/#{strategy.name}", :sign_in}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec plug(MagicLink.t(), Strategy.phase(), Conn.t()) :: Conn.t()
|
||||||
|
def plug(strategy, :request, conn), do: MagicLink.Plug.request(conn, strategy)
|
||||||
|
def plug(strategy, :sign_in, conn), do: MagicLink.Plug.sign_in(conn, strategy)
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec action(MagicLink.t(), Strategy.action(), map, keyword) ::
|
||||||
|
:ok | {:ok, Resource.record()} | {:error, any}
|
||||||
|
def action(strategy, :request, params, options),
|
||||||
|
do: MagicLink.Actions.request(strategy, params, options)
|
||||||
|
|
||||||
|
def action(strategy, :sign_in, params, options),
|
||||||
|
do: MagicLink.Actions.sign_in(strategy, params, options)
|
||||||
|
end
|
116
lib/ash_authentication/strategies/magic_link/transformer.ex
Normal file
116
lib/ash_authentication/strategies/magic_link/transformer.ex
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.Transformer do
|
||||||
|
@moduledoc """
|
||||||
|
DSL transformer for magic links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Ash.Resource
|
||||||
|
alias AshAuthentication.Strategy.MagicLink
|
||||||
|
alias Spark.Dsl.Transformer
|
||||||
|
import AshAuthentication.Utils
|
||||||
|
import AshAuthentication.Validations
|
||||||
|
import AshAuthentication.Strategy.Custom.Helpers
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec transform(MagicLink.t(), dsl_state) :: {:ok, MagicLink.t() | dsl_state} | {:error, any}
|
||||||
|
when dsl_state: map
|
||||||
|
def transform(strategy, dsl_state) do
|
||||||
|
with :ok <-
|
||||||
|
validate_token_generation_enabled(
|
||||||
|
dsl_state,
|
||||||
|
"Token generation must be enabled for magic links to work."
|
||||||
|
),
|
||||||
|
strategy <- maybe_set_sign_in_action_name(strategy),
|
||||||
|
strategy <- maybe_set_request_action_name(strategy),
|
||||||
|
{:ok, dsl_state} <-
|
||||||
|
maybe_build_action(
|
||||||
|
dsl_state,
|
||||||
|
strategy.sign_in_action_name,
|
||||||
|
&build_sign_in_action(&1, strategy)
|
||||||
|
),
|
||||||
|
{:ok, dsl_state} <-
|
||||||
|
maybe_build_action(
|
||||||
|
dsl_state,
|
||||||
|
strategy.request_action_name,
|
||||||
|
&build_request_action(&1, strategy)
|
||||||
|
) do
|
||||||
|
dsl_state =
|
||||||
|
dsl_state
|
||||||
|
|> then(
|
||||||
|
®ister_strategy_actions(
|
||||||
|
[strategy.sign_in_action_name, strategy.request_action_name],
|
||||||
|
&1,
|
||||||
|
strategy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> put_strategy(strategy)
|
||||||
|
|
||||||
|
{:ok, dsl_state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_set_sign_in_action_name(strategy) when is_nil(strategy.sign_in_action_name),
|
||||||
|
do: %{strategy | sign_in_action_name: :"sign_in_with_#{strategy.name}"}
|
||||||
|
|
||||||
|
defp maybe_set_sign_in_action_name(strategy), do: strategy
|
||||||
|
|
||||||
|
defp maybe_set_request_action_name(strategy) when is_nil(strategy.request_action_name),
|
||||||
|
do: %{strategy | request_action_name: :"request_#{strategy.name}"}
|
||||||
|
|
||||||
|
defp maybe_set_request_action_name(strategy), do: strategy
|
||||||
|
|
||||||
|
defp build_sign_in_action(_dsl_state, strategy) do
|
||||||
|
arguments = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||||
|
name: strategy.token_param_name,
|
||||||
|
type: :string,
|
||||||
|
allow_nil?: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
preparations = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||||
|
preparation: MagicLink.SignInPreparation
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
metadata = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
|
||||||
|
name: :token,
|
||||||
|
type: :string,
|
||||||
|
allow_nil?: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||||
|
name: strategy.sign_in_action_name,
|
||||||
|
arguments: arguments,
|
||||||
|
preparations: preparations,
|
||||||
|
metadata: metadata,
|
||||||
|
get?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_request_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
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
preparations = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||||
|
preparation: MagicLink.RequestPreparation
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||||
|
name: strategy.request_action_name,
|
||||||
|
arguments: arguments,
|
||||||
|
preparations: preparations
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
88
lib/ash_authentication/strategies/magic_link/verifier.ex
Normal file
88
lib/ash_authentication/strategies/magic_link/verifier.ex
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLink.Verifier do
|
||||||
|
@moduledoc """
|
||||||
|
DSL verifier for magic links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias AshAuthentication.{Strategy.MagicLink}
|
||||||
|
alias Spark.Error.DslError
|
||||||
|
import AshAuthentication.Validations
|
||||||
|
import AshAuthentication.Validations.Action
|
||||||
|
import AshAuthentication.Validations.Attribute
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec verify(MagicLink.t(), map) :: :ok | {:error, Exception.t()}
|
||||||
|
def verify(strategy, dsl_state) do
|
||||||
|
with {:ok, identity_attribute} <- validate_identity_attribute(dsl_state, strategy),
|
||||||
|
:ok <- validate_request_action(dsl_state, strategy, identity_attribute) do
|
||||||
|
validate_sign_in_action(dsl_state, strategy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_identity_attribute(dsl_state, strategy) do
|
||||||
|
with {:ok, identity_attribute} <- find_attribute(dsl_state, strategy.identity_field),
|
||||||
|
:ok <-
|
||||||
|
validate_attribute_unique_constraint(
|
||||||
|
dsl_state,
|
||||||
|
[strategy.identity_field],
|
||||||
|
strategy.resource
|
||||||
|
) do
|
||||||
|
{:ok, identity_attribute}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_request_action(dsl_state, strategy, identity_attribute) do
|
||||||
|
with {:ok, action} <- validate_action_exists(dsl_state, strategy.request_action_name),
|
||||||
|
:ok <- validate_action_has_argument(action, strategy.identity_field),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(
|
||||||
|
action,
|
||||||
|
strategy.identity_field,
|
||||||
|
:type,
|
||||||
|
[identity_attribute.type]
|
||||||
|
),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(action, strategy.identity_field, :allow_nil?, [
|
||||||
|
false
|
||||||
|
]),
|
||||||
|
:ok <- validate_action_has_preparation(action, MagicLink.RequestPreparation),
|
||||||
|
:ok <- validate_field_in_values(action, :type, [:read]) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, message} when is_binary(message) ->
|
||||||
|
{:error,
|
||||||
|
DslError.exception(
|
||||||
|
path: [:actions, :read, strategy.request_action_name, :type],
|
||||||
|
mesasge: message
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, exception} when is_exception(exception) ->
|
||||||
|
{:error, exception}
|
||||||
|
end
|
||||||
|
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_action_has_argument(action, strategy.token_param_name),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(action, strategy.token_param_name, :type, [
|
||||||
|
:string,
|
||||||
|
Ash.Type.String
|
||||||
|
]),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(action, strategy.token_param_name, :allow_nil?, [false]),
|
||||||
|
:ok <- validate_action_has_preparation(action, MagicLink.SignInPreparation),
|
||||||
|
:ok <- validate_field_in_values(action, :type, [:read]) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, message} when is_binary(message) ->
|
||||||
|
{:error,
|
||||||
|
DslError.exception(
|
||||||
|
path: [:actions, :read, strategy.sign_in_action_name, :type],
|
||||||
|
mesasge: message
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, exception} when is_exception(exception) ->
|
||||||
|
{:error, exception}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -103,7 +103,7 @@ defmodule AshAuthentication.Strategy.Password.Dsl do
|
||||||
doc: """
|
doc: """
|
||||||
The name to use for the sign in action.
|
The name to use for the sign in action.
|
||||||
|
|
||||||
If not present it will be generated by prependign the strategy name
|
If not present it will be generated by prepending the strategy name
|
||||||
with `sign_in_with_`.
|
with `sign_in_with_`.
|
||||||
""",
|
""",
|
||||||
required: false
|
required: false
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
|
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Prepare a query for a password reset request.
|
Prepare a query for a password reset request.
|
||||||
|
|
||||||
This preparation performs three jobs, one before the query executes and two
|
This preparation performs three jobs, one before the query executes and two
|
||||||
after.
|
after:
|
||||||
Firstly, it constraints the query to match the identity field passed to the
|
1. it constraints the query to match the identity field passed to the action.
|
||||||
action.
|
2. if there is a user returned by the query, then
|
||||||
Secondly, if there is a user returned by the query, then generate a reset
|
a. generate a reset token and
|
||||||
token and publish a notification. Always returns an empty result.
|
b. publish a notification.
|
||||||
|
|
||||||
|
Always returns an empty result.
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Preparation
|
use Ash.Resource.Preparation
|
||||||
alias Ash.{Query, Resource.Preparation}
|
alias Ash.{Query, Resource.Preparation}
|
||||||
|
|
|
@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Preparation
|
use Ash.Resource.Preparation
|
||||||
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
|
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
|
||||||
alias Ash.{Query, Resource.Preparation}
|
alias Ash.{Query, Resource, Resource.Preparation}
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
@ -81,7 +81,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
|
||||||
defp maybe_generate_token(record) do
|
defp maybe_generate_token(record) do
|
||||||
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
|
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
|
||||||
{:ok, token, _claims} = Jwt.token_for_user(record)
|
{:ok, token, _claims} = Jwt.token_for_user(record)
|
||||||
%{record | __metadata__: Map.put(record.__metadata__, :token, token)}
|
Resource.put_metadata(record, :token, token)
|
||||||
else
|
else
|
||||||
record
|
record
|
||||||
end
|
end
|
||||||
|
|
|
@ -217,7 +217,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
|
||||||
metadata =
|
metadata =
|
||||||
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do
|
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do
|
||||||
[
|
[
|
||||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :metadata,
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
|
||||||
name: :token,
|
name: :token,
|
||||||
type: :string,
|
type: :string,
|
||||||
allow_nil?: false
|
allow_nil?: false
|
||||||
|
|
|
@ -111,16 +111,11 @@ defmodule AshAuthentication.Validations do
|
||||||
@doc """
|
@doc """
|
||||||
Ensure that token generation is enabled for the resource.
|
Ensure that token generation is enabled for the resource.
|
||||||
"""
|
"""
|
||||||
@spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()}
|
@spec validate_token_generation_enabled(Dsl.t(), binary) :: :ok | {:error, Exception.t()}
|
||||||
def validate_token_generation_enabled(dsl_state) do
|
def validate_token_generation_enabled(dsl_state, message) do
|
||||||
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
|
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
|
||||||
do: :ok,
|
do: :ok,
|
||||||
else:
|
else: {:error, DslError.exception(path: [:tokens], message: message)}
|
||||||
{:error,
|
|
||||||
DslError.exception(
|
|
||||||
path: [:tokens],
|
|
||||||
message: "Token generation must be enabled for password resets to work."
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
7
mix.exs
7
mix.exs
|
@ -60,8 +60,11 @@ defmodule AshAuthentication.MixProject do
|
||||||
],
|
],
|
||||||
Strategies: [
|
Strategies: [
|
||||||
AshAuthentication.Strategy,
|
AshAuthentication.Strategy,
|
||||||
AshAuthentication.Strategy.Password,
|
AshAuthentication.Strategy.Auth0,
|
||||||
AshAuthentication.Strategy.OAuth2
|
AshAuthentication.Strategy.Github,
|
||||||
|
AshAuthentication.Strategy.MagicLink,
|
||||||
|
AshAuthentication.Strategy.OAuth2,
|
||||||
|
AshAuthentication.Strategy.Password
|
||||||
],
|
],
|
||||||
"Add ons": [
|
"Add ons": [
|
||||||
AshAuthentication.AddOn.Confirmation
|
AshAuthentication.AddOn.Confirmation
|
||||||
|
|
21
test/ash_authentication/strategies/magic_link_test.exs
Normal file
21
test/ash_authentication/strategies/magic_link_test.exs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule AshAuthentication.Strategy.MagicLinkTest do
|
||||||
|
@moduledoc false
|
||||||
|
use DataCase, async: true
|
||||||
|
|
||||||
|
import Plug.Test
|
||||||
|
alias AshAuthentication.{Info, Jwt, Plug, Strategy, Strategy.MagicLink}
|
||||||
|
|
||||||
|
doctest MagicLink
|
||||||
|
|
||||||
|
describe "request_token_for/2" do
|
||||||
|
test "it generates a sign in token" do
|
||||||
|
user = build_user()
|
||||||
|
strategy = Info.strategy!(Example.User, :magic_link)
|
||||||
|
|
||||||
|
assert {:ok, token} = MagicLink.request_token_for(strategy, user)
|
||||||
|
|
||||||
|
assert {:ok, claims} = Jwt.peek(token)
|
||||||
|
assert claims["act"] == to_string(strategy.sign_in_action_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -22,7 +22,6 @@ defmodule AshAuthentication.Strategy.PasswordTest do
|
||||||
strategy = %Password{resettable: [resettable], resource: user.__struct__}
|
strategy = %Password{resettable: [resettable], resource: user.__struct__}
|
||||||
|
|
||||||
assert {:ok, token} = Password.reset_token_for(strategy, user)
|
assert {:ok, token} = Password.reset_token_for(strategy, user)
|
||||||
|
|
||||||
assert {:ok, claims} = Jwt.peek(token)
|
assert {:ok, claims} = Jwt.peek(token)
|
||||||
assert claims["act"] == to_string(resettable.password_reset_action_name)
|
assert claims["act"] == to_string(resettable.password_reset_action_name)
|
||||||
end
|
end
|
||||||
|
|
|
@ -197,6 +197,16 @@ defmodule Example.User do
|
||||||
case_sensitive?(false)
|
case_sensitive?(false)
|
||||||
name_field(:username)
|
name_field(:username)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
magic_link do
|
||||||
|
sender fn user, token, _opts ->
|
||||||
|
Logger.debug("Magic link request for #{user.username}, token #{inspect(token)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tokens do
|
||||||
|
store_all_tokens? true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue