Packages
ash_authentication
4.14.0
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/ash_authentication.upgrade.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AshAuthentication.Upgrade do
@moduledoc false
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
# Groups allow for overlapping arguments for tasks by the same author
# See the generators guide for more.
group: :ash_authentication,
# *other* dependencies to add
# i.e `{:foo, "~> 2.0"}`
adds_deps: [],
# *other* dependencies to add and call their associated installers, if they exist
# i.e `{:foo, "~> 2.0"}`
installs: [],
# An example invocation
# example: __MODULE__.Docs.example(),
example: "example",
# a list of positional arguments, i.e `[:file]`
positional: [:from, :to],
# Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv
# This ensures your option schema includes options from nested tasks
composes: [],
# `OptionParser` schema
schema: [],
# Default values for the options in the `schema`
defaults: [],
# CLI aliases
aliases: [],
# A list of options in the schema that are required
required: []
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
positional = igniter.args.positional
options = igniter.args.options
upgrades =
%{
"4.4.9" => [&fix_token_is_revoked_action/2],
"4.13.4" => [&add_remember_me_to_magic_link_sign_in/2],
"4.14.0" => [&require_identity_resource/2]
}
# For each version that requires a change, add it to this map
# Each key is a version that points at a list of functions that take an
# igniter and options (i.e. flags or other custom options).
# See the upgrades guide for more.
Igniter.Upgrades.run(igniter, positional.from, positional.to, upgrades,
custom_opts: options
)
end
def fix_token_is_revoked_action(igniter, _opts) do
case find_all_token_resources(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
maybe_fix_is_revoked_action(igniter, resource)
end)
end
end
defp find_all_token_resources(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
{:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :extensions) do
if Igniter.Code.List.list?(zipper) do
match?(
{:ok, _},
Igniter.Code.List.move_to_list_item(
zipper,
&Igniter.Code.Common.nodes_equal?(&1, AshAuthentication.TokenResource)
)
)
else
Igniter.Code.Common.nodes_equal?(zipper, AshAuthentication.TokenResource)
end
end
end)
end
defp maybe_fix_is_revoked_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :action, :revoked?),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
{:ok, zipper} <- remove_argument_option(zipper, :token, :allow_nil?),
{:ok, zipper} <- remove_argument_option(zipper, :jti, :allow_nil?) do
add_action_return_type(zipper, :boolean)
else
:error -> {:ok, zipper}
end
end)
end
defp move_to_action(zipper, type, name) do
Igniter.Code.Function.move_to_function_call(
zipper,
type,
2,
&Igniter.Code.Function.argument_equals?(&1, 0, name)
)
end
defp add_action_return_type(zipper, type) do
zipper = Sourceror.Zipper.top(zipper)
with {:ok, zipper} <- move_to_action(zipper, :action, :revoked?),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do
{:ok,
Sourceror.Zipper.insert_left(
zipper,
quote do
unquote(type)
end
)}
end
end
defp remove_argument_option(zipper, argument_name, option) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call(
zipper,
:argument,
3,
&Igniter.Code.Function.argument_equals?(&1, 0, argument_name)
),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 2) do
if Igniter.Code.List.find_list_item_index(
zipper,
&Igniter.Code.Tuple.elem_equals?(&1, 0, :do)
) do
Igniter.Code.Common.within(zipper, fn zipper ->
{:ok,
Igniter.Code.Common.remove(
zipper,
&Igniter.Code.Function.function_call?(&1, option, 1)
)}
end)
else
Igniter.Code.List.remove_from_list(
zipper,
&Igniter.Code.Tuple.elem_equals?(&1, 0, option)
)
end
end
end
def add_remember_me_to_magic_link_sign_in(igniter, _opts) do
case find_resources_with_magic_link_and_remember_me(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
maybe_add_remember_me_to_magic_link_action(igniter, resource)
end)
end
end
defp find_resources_with_magic_link_and_remember_me(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
with {:ok, zipper} <- enter_auth_strategies(zipper),
true <- has_strategy?(zipper, :magic_link),
true <- has_strategy?(zipper, :remember_me) do
true
else
_ -> false
end
end)
end
defp enter_auth_strategies(zipper) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:authentication,
1
),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
{:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:strategies,
1
) do
Igniter.Code.Common.move_to_do_block(zipper)
end
end
defp has_strategy?(zipper, strategy_type) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
strategy_type,
[1, 2]
)
)
end
defp maybe_add_remember_me_to_magic_link_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :create, :sign_in_with_magic_link),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper) do
do_block_zipper
|> maybe_add_remember_me_argument()
|> maybe_add_remember_me_change()
else
:error -> {:ok, zipper}
end
end)
end
defp maybe_add_remember_me_argument(zipper) do
if has_remember_me_argument?(zipper) do
zipper
else
add_remember_me_argument(zipper)
end
end
defp has_remember_me_argument?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:argument,
[2, 3],
&Igniter.Code.Function.argument_equals?(&1, 0, :remember_me)
)
)
end
defp add_remember_me_argument(zipper) do
argument_code = """
argument :remember_me, :boolean do
description "Whether to generate a remember me token"
allow_nil? true
end
"""
Igniter.Code.Common.add_code(zipper, argument_code)
end
defp maybe_add_remember_me_change(zipper) do
if has_remember_me_change?(zipper) do
{:ok, zipper}
else
{:ok, add_remember_me_change(zipper)}
end
end
defp has_remember_me_change?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:change,
1,
fn change_zipper ->
case Igniter.Code.Function.move_to_nth_argument(change_zipper, 0) do
{:ok, arg_zipper} ->
source = Sourceror.Zipper.node(arg_zipper) |> Sourceror.to_string()
String.contains?(source, "MaybeGenerateTokenChange")
_ ->
false
end
end
)
)
end
defp add_remember_me_change(zipper) do
change_code = """
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me}
"""
Igniter.Code.Common.add_code(zipper, change_code)
end
@oauth2_family ~w[oauth2 oidc github google auth0 apple slack]a
def require_identity_resource(igniter, _opts) do
case find_resources_with_oauth2_strategies(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
resources
|> Enum.reduce(igniter, &ensure_identity_resource/2)
|> Igniter.add_notice("""
The user identity resource's unique key changed from
`(strategy, uid, user_id)` to `(strategy, uid)`, so that a provider's
`iss`/`sub` resolves to exactly one local user.
Run `mix ash.codegen require_user_identity_unique_key` (and then
`mix ash.migrate`) to generate the migration that swaps the unique
index.
IMPORTANT: the new index will fail to create if your data contains the
same `(strategy, uid)` linked to more than one user. That should not
happen under normal use, but if it does you must reconcile those rows
before migrating - it indicates a provider identity was linked to
multiple accounts.
""")
end
end
defp find_resources_with_oauth2_strategies(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
case enter_auth_strategies(zipper) do
{:ok, zipper} -> Enum.any?(@oauth2_family, &has_strategy?(zipper, &1))
_ -> false
end
end)
end
defp ensure_identity_resource(resource, igniter) do
identity_resource = conventional_identity_resource(resource)
case Igniter.Project.Module.module_exists(igniter, identity_resource) do
{true, igniter} ->
Enum.reduce(
@oauth2_family,
igniter,
&wire_identity_resource(&2, resource, &1, identity_resource)
)
{false, igniter} ->
Igniter.add_warning(
igniter,
missing_identity_resource_warning(resource, identity_resource)
)
end
end
defp wire_identity_resource(igniter, resource, type, identity_resource) do
case AshAuthentication.Igniter.defines_strategy_of_type(igniter, resource, type) do
{igniter, true} ->
igniter
|> add_identity_resource_to_strategy(resource, type, identity_resource)
|> ensure_register_action_has_identity_change(resource, type)
{igniter, false} ->
igniter
end
end
defp conventional_identity_resource(resource) do
resource
|> Module.split()
|> :lists.droplast()
|> Enum.concat(["UserIdentity"])
|> Module.concat()
end
defp add_identity_resource_to_strategy(igniter, resource, type, identity_resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, zipper} <- enter_auth_strategies(zipper),
{:ok, strategy_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(strategy_zipper),
:error <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
do_block_zipper,
:identity_resource,
1
) do
{:ok,
Igniter.Code.Common.add_code(
do_block_zipper,
"identity_resource #{inspect(identity_resource)}"
)}
else
_ -> {:ok, zipper}
end
end)
end
# sobelow_skip ["DOS.BinToAtom"]
defp ensure_register_action_has_identity_change(igniter, resource, type) do
action_name = :"register_with_#{type}"
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :create, action_name),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
false <- has_identity_change?(do_block_zipper) do
{:ok,
Igniter.Code.Common.add_code(
do_block_zipper,
"change AshAuthentication.Strategy.OAuth2.IdentityChange"
)}
else
_ -> {:ok, zipper}
end
end)
end
defp has_identity_change?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:change,
1,
fn change_zipper ->
case Igniter.Code.Function.move_to_nth_argument(change_zipper, 0) do
{:ok, arg_zipper} ->
arg_zipper
|> Sourceror.Zipper.node()
|> Sourceror.to_string()
|> String.contains?("IdentityChange")
_ ->
false
end
end
)
)
end
defp missing_identity_resource_warning(resource, identity_resource) do
"""
#{inspect(resource)} has one or more OAuth2/OIDC strategies but no user
identity resource could be found at #{inspect(identity_resource)}.
As of this release, OAuth2 and OIDC strategies require an `identity_resource`.
Matching a local user by their email address (or any other provider claim)
is unsafe - only the provider's `iss`/`sub` claims uniquely and stably
identify an end-user, and those are persisted in the identity resource.
To resolve this manually:
1. Create a user identity resource (conventionally #{inspect(identity_resource)}):
defmodule #{inspect(identity_resource)} do
use Ash.Resource,
extensions: [AshAuthentication.UserIdentity],
domain: <your domain>
user_identity do
user_resource #{inspect(resource)}
end
# ... data layer, postgres/sqlite block, etc.
end
2. Add `identity_resource #{inspect(identity_resource)}` to each OAuth2/OIDC
strategy on #{inspect(resource)}.
3. Add `change AshAuthentication.Strategy.OAuth2.IdentityChange` to each
`register_with_*` action for those strategies.
See the "User Identities" section of the strategy documentation for details.
"""
end
end
else
defmodule Mix.Tasks.AshAuthentication.Upgrade do
@moduledoc false
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.upgrade' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end