From 776bd8ea6c86ad4cb30fff64b2aa6b81670531ca Mon Sep 17 00:00:00 2001 From: James Harton Date: Wed, 30 Nov 2022 16:32:13 +1300 Subject: [PATCH] improvement(TokenResource)!: Move `TokenRevocation` -> `TokenResource`. This paves the way to fix #47. --- lib/ash_authentication/application.ex | 2 +- lib/ash_authentication/dsl.ex | 14 +- lib/ash_authentication/jwt/config.ex | 8 +- lib/ash_authentication/plug/helpers.ex | 6 +- lib/ash_authentication/token_resource.ex | 161 +++++++++++ .../token_resource/actions.ex | 119 ++++++++ .../token_resource/expunger.ex | 94 +++++++ lib/ash_authentication/token_resource/info.ex | 10 + .../token_resource/is_revoked_preparation.ex | 42 +++ .../token_resource/revoke_token_change.ex | 27 ++ .../token_resource/transformer.ex | 258 ++++++++++++++++++ lib/ash_authentication/token_revocation.ex | 191 ------------- .../token_revocation/expunger.ex | 53 ---- .../token_revocation/info.ex | 10 - .../token_revocation/revoke_token_change.ex | 36 --- .../token_revocation/transformer.ex | 197 ------------- lib/ash_authentication/transformer.ex | 44 ++- lib/ash_authentication/utils.ex | 83 +++++- mix.exs | 2 +- mix.lock | 2 +- .../20221002235526_migrate_resources1.exs | 23 -- ...21020042559_add_token_revocation_table.exs | 28 -- ...add_confirmed_at_to_user_wuth_username.exs | 21 -- ...5_remove_non_null_from_hashed_password.exs | 21 -- ...0221109212946_add_user_identitis_table.exs | 42 --- ...> 20221129214829_install_2_extensions.exs} | 0 .../20221129214830_migrate_resources1.exs | 83 ++++++ .../20221129214830.json} | 24 +- .../20221129214830.json} | 6 +- ...0221109212946.json => 20221129214830.json} | 14 +- .../user_with_username/20221002235526.json | 69 ----- .../user_with_username/20221020042559.json | 78 ------ .../user_with_username/20221104032457.json | 88 ------ test/ash_authentication/jwt/config_test.exs | 10 +- test/ash_authentication/jwt_test.exs | 2 +- test/ash_authentication/plug/helpers_test.exs | 2 +- .../token_resource_test.exs | 25 ++ .../token_revocation_test.exs | 26 -- test/support/data_case.ex | 12 - test/support/example/registry.ex | 2 +- test/support/example/token.ex | 15 + test/support/example/token_revocation.ex | 28 -- test/support/example/user.ex | 2 +- test/test_helper.exs | 2 +- 44 files changed, 994 insertions(+), 988 deletions(-) create mode 100644 lib/ash_authentication/token_resource.ex create mode 100644 lib/ash_authentication/token_resource/actions.ex create mode 100644 lib/ash_authentication/token_resource/expunger.ex create mode 100644 lib/ash_authentication/token_resource/info.ex create mode 100644 lib/ash_authentication/token_resource/is_revoked_preparation.ex create mode 100644 lib/ash_authentication/token_resource/revoke_token_change.ex create mode 100644 lib/ash_authentication/token_resource/transformer.ex delete mode 100644 lib/ash_authentication/token_revocation.ex delete mode 100644 lib/ash_authentication/token_revocation/expunger.ex delete mode 100644 lib/ash_authentication/token_revocation/info.ex delete mode 100644 lib/ash_authentication/token_revocation/revoke_token_change.ex delete mode 100644 lib/ash_authentication/token_revocation/transformer.ex delete mode 100644 priv/repo/migrations/20221002235526_migrate_resources1.exs delete mode 100644 priv/repo/migrations/20221020042559_add_token_revocation_table.exs delete mode 100644 priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs delete mode 100644 priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs delete mode 100644 priv/repo/migrations/20221109212946_add_user_identitis_table.exs rename priv/repo/migrations/{20221002235524_install_2_extensions.exs => 20221129214829_install_2_extensions.exs} (100%) create mode 100644 priv/repo/migrations/20221129214830_migrate_resources1.exs rename priv/resource_snapshots/repo/{token_revocations/20221020042559.json => tokens/20221129214830.json} (58%) rename priv/resource_snapshots/repo/{user_with_username/20221107021255.json => user/20221129214830.json} (93%) rename priv/resource_snapshots/repo/user_identities/{20221109212946.json => 20221129214830.json} (88%) delete mode 100644 priv/resource_snapshots/repo/user_with_username/20221002235526.json delete mode 100644 priv/resource_snapshots/repo/user_with_username/20221020042559.json delete mode 100644 priv/resource_snapshots/repo/user_with_username/20221104032457.json create mode 100644 test/ash_authentication/token_resource_test.exs delete mode 100644 test/ash_authentication/token_revocation_test.exs create mode 100644 test/support/example/token.ex delete mode 100644 test/support/example/token_revocation.ex diff --git a/lib/ash_authentication/application.ex b/lib/ash_authentication/application.ex index a0dd6c0..9b17f81 100644 --- a/lib/ash_authentication/application.ex +++ b/lib/ash_authentication/application.ex @@ -7,7 +7,7 @@ defmodule AshAuthentication.Application do @doc false @impl true def start(_type, _args) do - [AshAuthentication.TokenRevocation.Expunger] + [AshAuthentication.TokenResource.Expunger] |> maybe_append(start_dev_server?(), {DevServer, []}) |> maybe_append(start_repo?(), {Example.Repo, []}) |> Supervisor.start_link(strategy: :one_for_one, name: AshAuthentication.Supervisor) diff --git a/lib/ash_authentication/dsl.ex b/lib/ash_authentication/dsl.ex index be6fa85..e22e364 100644 --- a/lib/ash_authentication/dsl.ex +++ b/lib/ash_authentication/dsl.ex @@ -137,16 +137,16 @@ defmodule AshAuthentication.Dsl do """, default: @default_token_lifetime_days * 24 ], - revocation_resource: [ - type: {:behaviour, Resource}, + token_resource: [ + type: {:or, [{:behaviour, Resource}, {:in, [false]}]}, doc: """ - The resource used to store token revocation information. + The resource used to store token 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. - """ + store information about tokens, such as revocations and in-flight + confirmations. + """, + required: true ] ] }, diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex index ebe5890..b0fdf10 100644 --- a/lib/ash_authentication/jwt/config.ex +++ b/lib/ash_authentication/jwt/config.ex @@ -8,7 +8,7 @@ defmodule AshAuthentication.Jwt.Config do """ alias Ash.Resource - alias AshAuthentication.{Info, Jwt, TokenRevocation} + alias AshAuthentication.{Info, Jwt, TokenResource} alias Joken.{Config, Signer} @doc """ @@ -95,9 +95,9 @@ defmodule AshAuthentication.Jwt.Config 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) + case Info.authentication_tokens_token_resource(resource) do + {:ok, token_resource} -> + TokenResource.Actions.valid_jti?(token_resource, jti) _ -> false diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex index 3919d47..6cecae3 100644 --- a/lib/ash_authentication/plug/helpers.ex +++ b/lib/ash_authentication/plug/helpers.ex @@ -4,7 +4,7 @@ defmodule AshAuthentication.Plug.Helpers do """ alias Ash.{PlugHelpers, Resource} - alias AshAuthentication.{Info, Jwt, TokenRevocation} + alias AshAuthentication.{Info, Jwt, TokenResource} alias Plug.Conn @doc """ @@ -116,8 +116,8 @@ defmodule AshAuthentication.Plug.Helpers do |> Stream.map(&String.replace_leading(&1, "Bearer ", "")) |> Enum.reduce(conn, fn token, conn -> 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 + {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource), + :ok <- TokenResource.Actions.revoke(token_resource, token) do conn else _ -> conn diff --git a/lib/ash_authentication/token_resource.ex b/lib/ash_authentication/token_resource.ex new file mode 100644 index 0000000..a1c2900 --- /dev/null +++ b/lib/ash_authentication/token_resource.ex @@ -0,0 +1,161 @@ +defmodule AshAuthentication.TokenResource do + @default_expunge_interval_hrs 12 + + @dsl [ + %Spark.Dsl.Section{ + name: :token, + describe: "Configuration options for this token resource", + schema: [ + api: [ + type: {:behaviour, Ash.Api}, + doc: """ + The Ash API to use to access this resource. + """, + required: true + ], + expunge_expired_action_name: [ + type: :atom, + doc: """ + The name of the action used to remove expired tokens. + """, + default: :expunge_expired + ], + read_expired_action_name: [ + type: :atom, + doc: """ + The name of the action use to find all expired tokens. + + Used internally by the `expunge_expired` action. + """, + default: :read_expired + ], + expunge_interval: [ + type: :pos_integer, + doc: """ + How often to remove expired records. + + How often to scan this resource for records which have expired, and thus can be removed. + """, + default: @default_expunge_interval_hrs + ] + ], + sections: [ + %Spark.Dsl.Section{ + name: :revocation, + describe: "Configuration options for token revocation", + schema: [ + revoke_token_action_name: [ + type: :atom, + doc: """ + The name of the action used to revoke tokens. + """, + default: :revoke_token + ], + is_revoked_action_name: [ + type: :atom, + doc: """ + The name of the action used to check if a token is revoked. + """, + default: :revoked? + ] + ] + } + ] + } + ] + + @moduledoc """ + This is an Ash resource extension which generates the default token resource. + + The token resource is used to store information about tokens that should not + be shared with the end user. It does not actually contain any tokens. + + For example: + + * When an authentication token has been revoked + * When a confirmation token has changes to apply + + ## Storage + + The information stored in this resource is essentially ephemeral - all tokens + have an expiry date, so it doesn't make sense to keep them after that time has + passed. However, if you have any tokens with very long expiry times then we + suggest you store this resource in a resilient data-layer such as Postgres. + + ## Usage + + There is no need to define any attributes or actions (although you can if you + want). The extension will wire up everything that's needed for the token + system to function. + + ``` + defmodule MyApp.Accounts.Token do + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication.TokenResource] + + token do + api MyApp.Accounts + end + + postgres do + table "tokens" + repo MyApp.Repo + end + end + ``` + + Whilst it is possible to have multiple token resources, there is no need to do + so. + + ## Dsl + + ### Index + + #{Spark.Dsl.Extension.doc_index(@dsl)} + + ### Docs + + #{Spark.Dsl.Extension.doc(@dsl)} + """ + + alias Ash.Resource + alias AshAuthentication.TokenResource + + use Spark.Dsl.Extension, + sections: @dsl, + transformers: [TokenResource.Transformer] + + @doc """ + Has the token been revoked? + + Similar to `jti_revoked?/2..3` except that it extracts the JTI from the token, + rather than relying on it to be passed in. + """ + @spec token_revoked?(Resource.t(), String.t(), keyword) :: boolean + defdelegate token_revoked?(resource, token, opts \\ []), to: TokenResource.Actions + + @doc """ + Has the token been revoked? + + Similar to `token-revoked?/2..3` except that rather than extracting the JTI + from the token, assumes that it's being passed in directly. + """ + @spec jti_revoked?(Resource.t(), String.t(), keyword) :: boolean + defdelegate jti_revoked?(resource, jti, opts \\ []), to: TokenResource.Actions + + @doc """ + Revoke a token. + + Extracts the JTI from the provided token and uses it to generate a revocationr + record. + """ + @spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any} + defdelegate revoke(resource, token, opts \\ []), to: TokenResource.Actions + + @doc """ + Remove all expired records. + """ + @spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any} + defdelegate expunge_expired(resource, opts \\ []), to: TokenResource.Actions +end diff --git a/lib/ash_authentication/token_resource/actions.ex b/lib/ash_authentication/token_resource/actions.ex new file mode 100644 index 0000000..8e19f3c --- /dev/null +++ b/lib/ash_authentication/token_resource/actions.ex @@ -0,0 +1,119 @@ +defmodule AshAuthentication.TokenResource.Actions do + @moduledoc """ + The code interface for interacting with the token resource. + """ + + alias Ash.{Changeset, DataLayer, Query, Resource} + alias AshAuthentication.{TokenResource, TokenResource.Info} + + import AshAuthentication.Utils + + @doc false + @spec read_expired(Resource.t(), keyword) :: {:ok, [Resource.record()]} | {:error, any} + def read_expired(resource, opts \\ []) do + with :ok <- assert_resource_has_extension(resource, TokenResource), + {:ok, api} <- Info.token_api(resource), + {:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource) do + resource + |> Query.for_read(read_expired_action_name, opts) + |> api.read() + end + end + + @doc """ + Remove all expired records. + """ + @spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any} + def expunge_expired(resource, opts \\ []) do + DataLayer.transaction(resource, fn -> expunge_inside_transaction(resource, opts) end, 5000) + end + + @doc """ + Has the token been revoked? + + Similar to `jti_revoked?/2..3` except that it extracts the JTI from the token, + rather than relying on it to be passed in. + """ + @spec token_revoked?(Resource.t(), String.t(), keyword) :: boolean + def token_revoked?(resource, token, opts \\ []) do + with :ok <- assert_resource_has_extension(resource, TokenResource), + {:ok, api} <- Info.token_api(resource), + {:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do + resource + |> Query.for_read(is_revoked_action_name, %{"token" => token}, opts) + |> api.read() + |> case do + {:ok, []} -> false + {:ok, _} -> true + _ -> false + end + end + end + + @doc """ + Has the token been revoked? + + Similar to `token-revoked?/2..3` except that rather than extracting the JTI + from the token, assumes that it's being passed in directly. + """ + @spec jti_revoked?(Resource.t(), String.t(), keyword) :: boolean + def jti_revoked?(resource, jti, opts \\ []) do + with :ok <- assert_resource_has_extension(resource, TokenResource), + {:ok, api} <- Info.token_api(resource), + {:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do + resource + |> Query.for_read(is_revoked_action_name, %{"jti" => jti}, opts) + |> api.read() + |> case do + {:ok, []} -> false + {:ok, _} -> true + _ -> false + end + end + end + + @doc false + @spec valid_jti?(Resource.t(), String.t(), keyword) :: boolean + def valid_jti?(resource, jti, opts \\ []), do: !jti_revoked?(resource, jti, opts) + + @doc """ + Revoke a token. + + Extracts the JTI from the provided token and uses it to generate a revocationr + record. + """ + @spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any} + def revoke(resource, token, opts \\ []) do + with :ok <- assert_resource_has_extension(resource, TokenResource), + {:ok, api} <- Info.token_api(resource), + {:ok, revoke_token_action_name} <- + Info.token_revocation_revoke_token_action_name(resource) do + resource + |> Changeset.for_create(revoke_token_action_name, %{"token" => token}, opts) + |> api.create() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + defp expunge_inside_transaction(resource, opts) do + with :ok <- assert_resource_has_extension(resource, TokenResource), + {:ok, api} <- Info.token_api(resource), + {:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource), + query <- Query.for_read(resource, read_expired_action_name, opts), + {:ok, expired} <- api.read(query), + {:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(resource) do + Enum.reduce_while(expired, :ok, fn record, :ok -> + record + |> Changeset.for_destroy(expunge_expired_action_name, opts) + |> api.destroy() + |> case do + {:ok, _} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end +end diff --git a/lib/ash_authentication/token_resource/expunger.ex b/lib/ash_authentication/token_resource/expunger.ex new file mode 100644 index 0000000..307a1be --- /dev/null +++ b/lib/ash_authentication/token_resource/expunger.ex @@ -0,0 +1,94 @@ +defmodule AshAuthentication.TokenResource.Expunger do + @refresh_interval_hrs 1 + + @moduledoc """ + A `GenServer` which periodically removes expired token revocations. + + Scans all token revocation resources based on their configured expunge + interval and removes any expired records. + + ```elixir + defmodule MyApp.Accounts.Token do + use Ash.Resource, + extensions: [AshAuthentication.TokenResource] + + token do + api MyApp.Accounts + expunge_interval 12 + end + end + ``` + + This server is started automatically as part of the `:ash_authentication` + supervision tree. + + Scans through all resources every #{if @refresh_interval_hrs == 1, + do: "hour", + else: "#{@refresh_interval_hrs} hours"} checking to make sure that no + resources have been added or removed which need checking. This allows us to + support dynamically loaded and hot-reloaded modules. + """ + + use GenServer + alias AshAuthentication.{TokenResource, TokenResource.Actions, TokenResource.Info} + + @doc false + @spec start_link(any) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) + + @doc false + @impl true + @spec init(any) :: {:ok, :timer.tref()} + def init(_) do + state = + %{} + |> refresh_state() + + {:ok, _} = :timer.send_interval(@refresh_interval_hrs * 60 * 60 * 1000, :refresh_state) + + {:ok, state} + end + + @doc false + @impl true + @spec handle_info(any, map) :: {:noreply, map} + def handle_info(:refresh_state, state) do + state = + state + |> refresh_state() + + {:noreply, state} + end + + def handle_info({:expunge, resource}, state) when :erlang.is_map_key(resource, state) do + resource + |> Actions.expunge_expired() + + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp refresh_state(state) do + :code.all_loaded() + |> Stream.map(&elem(&1, 0)) + |> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0)) + |> Stream.filter(&(TokenResource in Spark.extensions(&1))) + |> Stream.map(&{&1, Info.token_expunge_interval!(&1) * 60 * 60 * 1000}) + |> Enum.reduce(state, fn {module, interval}, state -> + case Map.get(state, module) do + %{interval: ^interval, timer: timer} when not is_nil(timer) -> + state + + %{timer: timer} when not is_nil(timer) -> + :timer.cancel(timer) + {:ok, timer} = :timer.send_interval(interval, {:expunge, module}) + Map.put(state, module, %{interval: interval, timer: timer}) + + _ -> + {:ok, timer} = :timer.send_interval(interval, {:expunge, module}) + Map.put(state, module, %{interval: interval, timer: timer}) + end + end) + end +end diff --git a/lib/ash_authentication/token_resource/info.ex b/lib/ash_authentication/token_resource/info.ex new file mode 100644 index 0000000..9778f2e --- /dev/null +++ b/lib/ash_authentication/token_resource/info.ex @@ -0,0 +1,10 @@ +defmodule AshAuthentication.TokenResource.Info do + @moduledoc """ + Introspection functions for the `AshAuthentication.TokenResource` Ash + extension. + """ + + use AshAuthentication.InfoGenerator, + extension: AshAuthentication.TokenResource, + sections: [:token] +end diff --git a/lib/ash_authentication/token_resource/is_revoked_preparation.ex b/lib/ash_authentication/token_resource/is_revoked_preparation.ex new file mode 100644 index 0000000..6e5a5d9 --- /dev/null +++ b/lib/ash_authentication/token_resource/is_revoked_preparation.ex @@ -0,0 +1,42 @@ +defmodule AshAuthentication.TokenResource.IsRevokedPreparation do + @moduledoc """ + Constrains a query to only records which are revocations that match the token + or jti argument. + """ + + use Ash.Resource.Preparation + alias Ash.{Query, Resource.Preparation} + alias AshAuthentication.Jwt + require Ash.Query + + @doc false + @impl true + @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() + def prepare(query, _opts, _context) do + case get_jti(query) do + {:ok, jti} -> + query + |> Query.filter(purpose: "revocation", jti: jti) + |> Query.limit(1) + + :error -> + Query.limit(query, 0) + end + end + + defp get_jti(query) do + [:jti, :token] + |> Stream.map(&{&1, Query.get_argument(query, &1)}) + |> Stream.filter(&elem(&1, 1)) + |> Enum.reduce_while(:error, fn + {:jti, jti}, _ -> + {:halt, {:ok, jti}} + + {:token, token}, _ -> + case Jwt.peek(token) do + {:ok, %{"jti" => jti}} -> {:halt, {:ok, jti}} + _ -> {:cont, :error} + end + end) + end +end diff --git a/lib/ash_authentication/token_resource/revoke_token_change.ex b/lib/ash_authentication/token_resource/revoke_token_change.ex new file mode 100644 index 0000000..d62485e --- /dev/null +++ b/lib/ash_authentication/token_resource/revoke_token_change.ex @@ -0,0 +1,27 @@ +defmodule AshAuthentication.TokenResource.RevokeTokenChange do + @moduledoc """ + Generates a revocation record for a given token. + """ + + use Ash.Resource.Change + alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change} + alias AshAuthentication.Jwt + + @doc false + @impl true + @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() + def change(changeset, _opts, _context) do + with token when byte_size(token) > 0 <- Changeset.get_argument(changeset, :token), + {:ok, %{"jti" => jti, "exp" => exp}} <- Jwt.peek(token), + {:ok, expires_at} <- DateTime.from_unix(exp) do + changeset + |> Changeset.change_attributes(jti: jti, purpose: "revocation", expires_at: expires_at) + else + _ -> + changeset + |> Changeset.add_error([ + InvalidArgument.exception(field: :token, message: "is not a valid token") + ]) + end + end +end diff --git a/lib/ash_authentication/token_resource/transformer.ex b/lib/ash_authentication/token_resource/transformer.ex new file mode 100644 index 0000000..cb11fd2 --- /dev/null +++ b/lib/ash_authentication/token_resource/transformer.ex @@ -0,0 +1,258 @@ +defmodule AshAuthentication.TokenResource.Transformer do + @moduledoc """ + The token resource transformer. + + Sets up the default schema and actions for the token resource. + """ + + use Spark.Dsl.Transformer + require Ash.Expr + alias Ash.{Resource, Type} + alias AshAuthentication.{TokenResource, TokenResource.Info} + 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?(any) :: boolean() + def after?(Resource.Transformers.ValidatePrimaryActions), do: true + def after?(_), do: false + + @doc false + @impl true + @spec before?(any) :: boolean + def before?(Resource.Transformers.CachePrimaryKey), do: true + 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 + with {:ok, _api} <- validate_api_presence(dsl_state), + {:ok, dsl_state} <- + maybe_build_attribute(dsl_state, :jti, :string, + primary_key?: true, + allow_nil?: false, + sensitive?: true, + writable?: true + ), + :ok <- validate_jti_field(dsl_state), + {:ok, dsl_state} <- + maybe_build_attribute(dsl_state, :expires_at, :utc_datetime, + allow_nil?: false, + writable?: true + ), + :ok <- validate_expires_at_field(dsl_state), + {:ok, dsl_state} <- + maybe_build_attribute(dsl_state, :purpose, :string, + allow_nil?: false, + writable?: true + ), + :ok <- validate_purpose_field(dsl_state), + {:ok, dsl_state} <- + maybe_build_attribute(dsl_state, :extra_data, :map, + allow_nil?: true, + writable?: true + ), + :ok <- validate_extra_data_field(dsl_state), + {:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + expunge_expired_action_name, + &build_expunge_expired_action(&1, expunge_expired_action_name) + ), + :ok <- validate_expunge_expired_action(dsl_state, expunge_expired_action_name), + {:ok, read_expired_action_name} <- Info.token_read_expired_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + read_expired_action_name, + &build_read_expired_action(&1, read_expired_action_name) + ), + :ok <- validate_read_expired_action(dsl_state, read_expired_action_name), + {:ok, revoke_token_action_name} <- + Info.token_revocation_revoke_token_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + revoke_token_action_name, + &build_revoke_token_action(&1, revoke_token_action_name) + ), + :ok <- validate_revoke_token_action(dsl_state, revoke_token_action_name), + {:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + is_revoked_action_name, + &build_is_revoked_action(&1, is_revoked_action_name) + ), + :ok <- validate_is_revoked_action(dsl_state, is_revoked_action_name) do + {:ok, dsl_state} + end + end + + defp build_read_expired_action(_dsl_state, action_name) do + import Ash.Filter.TemplateHelpers + + Transformer.build_entity(Resource.Dsl, [:actions], :read, + name: action_name, + filter: expr(expires_at < now()) + ) + end + + defp validate_read_expired_action(dsl_state, action_name) do + with {:ok, action} <- validate_action_exists(dsl_state, action_name) do + validate_field_in_values(action, :type, [:read]) + end + end + + defp validate_is_revoked_action(dsl_state, action_name) do + with {:ok, action} <- validate_action_exists(dsl_state, action_name), + :ok <- + validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]), + :ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]), + :ok <- validate_action_has_preparation(action, TokenResource.IsRevokedPreparation) do + validate_field_in_values(action, :type, [:read]) + end + end + + defp build_is_revoked_action(_dsl_state, action_name) do + arguments = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, + name: :token, + type: :string, + allow_nil?: true, + sensitive?: true + ), + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, + name: :jti, + type: :string, + allow_nil?: true, + sensitive?: true + ) + ] + + preparations = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare, + preparation: TokenResource.IsRevokedPreparation + ) + ] + + Transformer.build_entity(Resource.Dsl, [:actions], :read, + name: action_name, + get?: true, + preparations: preparations, + arguments: arguments + ) + end + + defp validate_token_argument(action) do + with :ok <- + validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]), + :ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do + validate_action_argument_option(action, :token, :sensitive?, [true]) + end + end + + defp validate_revoke_token_action(dsl_state, revoke_token_action_name) do + with {:ok, action} <- validate_action_exists(dsl_state, revoke_token_action_name), + :ok <- validate_token_argument(action) do + validate_action_has_change(action, TokenResource.RevokeTokenChange) + end + end + + defp build_revoke_token_action(_dsl_state, action_name) do + arguments = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument, + name: :token, + type: :string, + allow_nil?: false, + sensitive?: true + ) + ] + + changes = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, + change: TokenResource.RevokeTokenChange + ) + ] + + Transformer.build_entity(Resource.Dsl, [:actions], :create, + name: action_name, + primary?: true, + arguments: arguments, + changes: changes, + accept: [:extra_data] + ) + end + + defp validate_expunge_expired_action(dsl_state, action_name) do + with {:ok, action} <- validate_action_exists(dsl_state, action_name) do + validate_field_in_values(action, :type, [:destroy]) + end + end + + defp build_expunge_expired_action(_dsl_state, action_name), + do: Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: action_name) + + defp validate_api_presence(dsl_state) do + case Transformer.get_option(dsl_state, [:token], :api) do + nil -> + {:error, + DslError.exception( + path: [:token, :api], + message: "An API module must be present" + )} + + api -> + {:ok, api} + end + end + + defp validate_jti_field(dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, :jti), + :ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]), + :ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]), + :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), + :ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do + validate_attribute_option(attribute, resource, :private?, [false]) + end + end + + defp validate_expires_at_field(dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, :expires_at), + :ok <- + validate_attribute_option(attribute, resource, :type, [Type.UtcDatetime, :utc_datetime]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do + validate_attribute_option(attribute, resource, :writable?, [true]) + end + end + + defp validate_purpose_field(dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, :purpose), + :ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do + validate_attribute_option(attribute, resource, :writable?, [true]) + end + end + + defp validate_extra_data_field(dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, :extra_data), + :ok <- validate_attribute_option(attribute, resource, :type, [Type.Map, :map]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]) do + validate_attribute_option(attribute, resource, :writable?, [true]) + end + end +end diff --git a/lib/ash_authentication/token_revocation.ex b/lib/ash_authentication/token_revocation.ex deleted file mode 100644 index 8f26228..0000000 --- a/lib/ash_authentication/token_revocation.ex +++ /dev/null @@ -1,191 +0,0 @@ -defmodule AshAuthentication.TokenRevocation do - @dsl [ - %Spark.Dsl.Section{ - name: :revocation, - describe: "Configure revocation options for this resource", - schema: [ - api: [ - type: {:behaviour, Ash.Api}, - doc: """ - The Ash API to use to access this resource. - """, - required: true - ] - ] - } - ] - - @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 (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 - - Token revocations are ephemeral, but their lifetime directly correlates to the - lifetime of your tokens - ie if you have a long expiry time on your tokens you - have to keep the revocation records for longer. Therefore we suggest a (semi) - permanent data layer, such as Postgres. - - ## Usage - - There is no need to define any attributes, etc. The extension will generate - them all for you. As there is no other use-case for this resource, it's - unlikely that you will need to customise it. - - ```elixir - defmodule MyApp.Accounts.TokenRevocation do - use Ash.Resource, - data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication.TokenRevocation] - - revocation do - api MyApp.Accounts - end - - postgres do - table "token_revocations" - repo MyApp.Repo - end - end - ``` - - Whilst it's possible to have multiple token revocation resources, in practice - there is no need to. - - ## Dsl - - ### Index - - #{Spark.Dsl.Extension.doc_index(@dsl)} - - ### Docs - - #{Spark.Dsl.Extension.doc(@dsl)} - """ - - use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.TokenRevocation.Transformer] - - alias AshAuthentication.TokenRevocation.Info - alias Ash.{Changeset, DataLayer, Query, Resource} - - @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.revocation_api(resource) do - resource - |> Changeset.for_create(:revoke_token, %{token: token}) - |> api.create(upsert?: true) - |> case do - {:ok, _} -> :ok - {:ok, _, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - end - - @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.revocation_api(resource) do - resource - |> Query.for_read(:revoked, %{jti: jti}) - |> api.read() - |> case do - {:ok, []} -> false - _ -> true - end - end - end - - @doc """ - The opposite of `revoked?/2` - """ - @spec valid?(Resource.t(), jti :: String.t()) :: boolean - def valid?(resource, jti), do: not revoked?(resource, jti) - - @doc """ - Expunge expired revocations. - - ## Note - - 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. - - Luckily, this function is only run periodically, so it shouldn't be a huge - cost. Contact the maintainers if it becomes a problem for you. - """ - @spec expunge(Resource.t()) :: :ok | {:error, any} - def expunge(resource) do - DataLayer.transaction( - resource, - fn -> - with {:ok, api} <- Info.revocation_api(resource), - query <- Query.for_read(resource, :expired), - {:ok, expired} <- api.read(query) do - expired - |> Stream.map(&remove_revocation/1) - |> Enum.reduce_while(:ok, fn - :ok, _ -> {:cont, :ok} - {:error, reason}, _ -> {:halt, {:error, reason}} - end) - end - end, - 5000 - ) - end - - @doc """ - Removes a revocation. - - ## Warning - - If the revocation in question is not yet expired, then this has the effect of - making this token valid again. - - You are unlikely to need to do this, as `AshAuthentication` will periodically - remove all expired revocations automatically, however it is provided here in - case you need it. - """ - @spec remove_revocation(Resource.record()) :: :ok | {:error, any} - def remove_revocation(revocation) do - with {:ok, api} <- Info.revocation_api(revocation.__struct__) do - revocation - |> Changeset.for_destroy(:expire) - |> api.destroy() - |> case do - :ok -> :ok - {:ok, _} -> :ok - {:ok, _, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - end -end diff --git a/lib/ash_authentication/token_revocation/expunger.ex b/lib/ash_authentication/token_revocation/expunger.ex deleted file mode 100644 index 1a046ad..0000000 --- a/lib/ash_authentication/token_revocation/expunger.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule AshAuthentication.TokenRevocation.Expunger do - @default_period_hrs 12 - - @moduledoc """ - A `GenServer` which periodically removes expired token revocations. - - Scans all token revocation resources every #{@default_period_hrs} hours and removes - any expired token revocations. - - You can change the expunger period by configuring it in your application - environment: - - ```elixir - config :ash_authentication, #{inspect(__MODULE__)}, - period_hrs: #{@default_period_hrs} - ``` - - This server is started automatically as part of the `:ash_authentication` - supervision tree. - """ - - use GenServer - alias AshAuthentication.TokenRevocation - - @doc false - @spec start_link(any) :: GenServer.on_start() - def start_link(opts), do: GenServer.start_link(__MODULE__, opts) - - @doc false - @impl true - @spec init(any) :: {:ok, :timer.tref()} - def init(_) do - period = - :ash_authentication - |> Application.get_env(__MODULE__, []) - |> Keyword.get(:period_hrs, @default_period_hrs) - |> then(&(&1 * 60 * 60 * 1000)) - - :timer.send_interval(period, :expunge) - end - - @doc false - @impl true - def handle_info(:expunge, tref) do - :code.all_loaded() - |> Stream.map(&elem(&1, 0)) - |> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0)) - |> Stream.filter(&(TokenRevocation in Spark.extensions(&1))) - |> Enum.each(&TokenRevocation.expunge/1) - - {:noreply, tref} - end -end diff --git a/lib/ash_authentication/token_revocation/info.ex b/lib/ash_authentication/token_revocation/info.ex deleted file mode 100644 index 8f0a22f..0000000 --- a/lib/ash_authentication/token_revocation/info.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule AshAuthentication.TokenRevocation.Info do - @moduledoc """ - Introspection functions for the `AshAuthentication.TokenRevocation` Ash - extension. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.TokenRevocation, - sections: [:revocation] -end diff --git a/lib/ash_authentication/token_revocation/revoke_token_change.ex b/lib/ash_authentication/token_revocation/revoke_token_change.ex deleted file mode 100644 index 46763ed..0000000 --- a/lib/ash_authentication/token_revocation/revoke_token_change.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do - @moduledoc """ - Decode the passed in token and build a revocation based on it's claims. - """ - - use Ash.Resource.Change - alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change} - alias AshAuthentication.Jwt - - @doc false - @impl true - @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() - def change(changeset, _opts, _) do - changeset - |> Changeset.before_action(fn changeset -> - changeset - |> Changeset.get_argument(:token) - |> Jwt.peek() - |> case do - {:ok, %{"jti" => jti, "exp" => exp}} -> - expires_at = - exp - |> DateTime.from_unix!() - - changeset - |> Changeset.change_attributes(jti: jti, expires_at: expires_at) - - {:ok, _} -> - {:error, "Invalid token"} - - {:error, reason} -> - {:error, InvalidArgument.exception(field: :token, message: to_string(reason))} - end - end) - end -end diff --git a/lib/ash_authentication/token_revocation/transformer.ex b/lib/ash_authentication/token_revocation/transformer.ex deleted file mode 100644 index 409c37b..0000000 --- a/lib/ash_authentication/token_revocation/transformer.ex +++ /dev/null @@ -1,197 +0,0 @@ -defmodule AshAuthentication.TokenRevocation.Transformer do - @moduledoc """ - The token revocation transformer. - - Sets up the default schema and actions for the token revocation resource. - """ - - use Spark.Dsl.Transformer - require Ash.Expr - alias Ash.Resource - alias AshAuthentication.TokenRevocation - 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?(any) :: boolean() - def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true - def after?(_), do: false - - @doc false - @impl true - @spec before?(any) :: boolean - def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true - 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 - with {:ok, _api} <- validate_api_presence(dsl_state), - {:ok, dsl_state} <- - maybe_build_attribute(dsl_state, :jti, :string, - primary_key?: true, - allow_nil?: false, - sensitive?: true, - writable?: true - ), - :ok <- validate_jti_field(dsl_state), - {:ok, dsl_state} <- - maybe_build_attribute(dsl_state, :expires_at, :utc_datetime, - allow_nil?: false, - writable?: true - ), - :ok <- validate_expires_at_field(dsl_state), - {:ok, dsl_state} <- - maybe_build_action(dsl_state, :revoke_token, &build_create_revoke_token_action/1), - :ok <- validate_revoke_token_action(dsl_state), - {:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_revoked_action/1), - :ok <- validate_read_revoked_action(dsl_state), - {:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_expired_action/1), - :ok <- validate_read_expired_action(dsl_state), - {:ok, dsl_state} <- - maybe_build_action(dsl_state, :destroy, &build_destroy_expire_action/1), - :ok <- validate_destroy_expire_action(dsl_state) do - {:ok, dsl_state} - end - end - - defp build_create_revoke_token_action(_dsl_state) do - arguments = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument, - name: :token, - type: :string, - allow_nil?: false, - sensitive?: true - ) - ] - - changes = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, - change: TokenRevocation.RevokeTokenChange - ) - ] - - Transformer.build_entity(Resource.Dsl, [:actions], :create, - name: :revoke_token, - primary?: true, - arguments: arguments, - changes: changes, - accept: [] - ) - end - - defp validate_revoke_token_action(dsl_state) do - with {:ok, action} <- validate_action_exists(dsl_state, :revoke_token), - :ok <- validate_token_argument(action) do - validate_action_has_change(action, TokenRevocation.RevokeTokenChange) - end - end - - defp validate_token_argument(action) do - with :ok <- - validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]), - :ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do - validate_action_argument_option(action, :token, :sensitive?, [true]) - end - end - - defp build_read_revoked_action(_dsl_state) do - import Ash.Filter.TemplateHelpers - - arguments = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, - name: :jti, - type: :string, - allow_nil?: false, - sensitive?: true - ) - ] - - Transformer.build_entity(Resource.Dsl, [:actions], :read, - name: :revoked, - get?: true, - filter: expr(jti == ^arg(:jti)), - arguments: arguments - ) - end - - defp validate_read_revoked_action(dsl_state) do - with {:ok, action} <- validate_action_exists(dsl_state, :revoked), - :ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]), - :ok <- validate_action_argument_option(action, :jti, :allow_nil?, [false]) do - validate_action_argument_option(action, :jti, :sensitive?, [true]) - end - end - - defp build_read_expired_action(_dsl_state) do - import Ash.Filter.TemplateHelpers - - Transformer.build_entity(Resource.Dsl, [:actions], :read, - name: :expired, - get?: true, - filter: expr(expires_at < now()) - ) - end - - defp validate_read_expired_action(dsl_state) do - with {:ok, _} <- validate_action_exists(dsl_state, :expired) do - :ok - end - end - - defp build_destroy_expire_action(_dsl_state), - do: - Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: :expire, primary?: true) - - defp validate_destroy_expire_action(dsl_state) do - with {:ok, _} <- validate_action_exists(dsl_state, :expire) do - :ok - end - end - - defp validate_jti_field(dsl_state) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, attribute} <- find_attribute(dsl_state, :jti), - :ok <- validate_attribute_option(attribute, resource, :type, [Ash.Type.String, :string]), - :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]), - :ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]), - :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), - :ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do - validate_attribute_option(attribute, resource, :private?, [false]) - end - end - - defp validate_expires_at_field(dsl_state) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, attribute} <- find_attribute(dsl_state, :expires_at), - :ok <- - validate_attribute_option(attribute, resource, :type, [ - Ash.Type.UtcDatetime, - :utc_datetime - ]), - :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do - validate_attribute_option(attribute, resource, :writable?, [true]) - end - end - - defp validate_api_presence(dsl_state) do - case Transformer.get_option(dsl_state, [:revocation], :api) do - nil -> - {:error, - DslError.exception( - path: [:revocation, :api], - message: "An API module must be present" - )} - - api -> - {:ok, api} - end - end -end diff --git a/lib/ash_authentication/transformer.ex b/lib/ash_authentication/transformer.ex index 8e6fd2e..9099cef 100644 --- a/lib/ash_authentication/transformer.ex +++ b/lib/ash_authentication/transformer.ex @@ -6,7 +6,8 @@ defmodule AshAuthentication.Transformer do """ use Spark.Dsl.Transformer - alias AshAuthentication.Info + alias Ash.Resource + alias AshAuthentication.{Info, TokenResource} alias Spark.{Dsl.Transformer, Error.DslError} import AshAuthentication.Utils import AshAuthentication.Validations @@ -15,13 +16,13 @@ defmodule AshAuthentication.Transformer do @doc false @impl true @spec after?(any) :: boolean() - def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true + def after?(Resource.Transformers.ValidatePrimaryActions), do: true def after?(_), do: false @doc false @impl true @spec before?(any) :: boolean - def before?(Ash.Resource.Transformers.DefaultAccept), do: true + def before?(Resource.Transformers.DefaultAccept), do: true def before?(_), do: false @doc false @@ -40,7 +41,7 @@ defmodule AshAuthentication.Transformer do &build_get_by_subject_action/1 ), :ok <- validate_read_action(dsl_state, get_by_subject_action_name), - :ok <- validate_token_revocation_resource(dsl_state), + :ok <- validate_token_resource(dsl_state), subject_name <- find_or_generate_subject_name(dsl_state) do dsl_state = dsl_state @@ -53,7 +54,7 @@ defmodule AshAuthentication.Transformer do defp build_get_by_subject_action(dsl_state) do with {:ok, get_by_subject_action_name} <- Info.authentication_get_by_subject_action_name(dsl_state) do - Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read, + Transformer.build_entity(Resource.Dsl, [:actions], :read, name: get_by_subject_action_name, get?: true ) @@ -73,29 +74,22 @@ defmodule AshAuthentication.Transformer do end end - defp validate_token_revocation_resource(dsl_state) do - if Transformer.get_option(dsl_state, [:tokens], :enabled?) do - with resource when not is_nil(resource) <- - Transformer.get_option(dsl_state, [:tokens], :revocation_resource), - Ash.Resource <- resource.spark_is(), - true <- AshAuthentication.TokenRevocation in Spark.extensions(resource) do + defp validate_token_resource(dsl_state) do + if_tokens_enabled(dsl_state, fn dsl_state -> + with {:ok, resource} when is_truthy(resource) <- + Info.authentication_tokens_token_resource(dsl_state), + :ok <- assert_resource_has_extension(resource, TokenResource) do :ok else - nil -> - {:error, - DslError.exception( - path: [:tokens, :revocation_resource], - message: "A revocation resource must be configured when tokens are enabled" - )} - - _ -> - {:error, - DslError.exception( - path: [:tokens, :revocation_resource], - message: - "The revocation resource must be an Ash resource with the `AshAuthentication.TokenRevocation` extension" - )} + {:ok, falsy} when is_falsy(falsy) -> :ok + {:error, reason} -> {:error, reason} end + end) + end + + defp if_tokens_enabled(dsl_state, validator) when is_function(validator, 1) do + if Info.authentication_tokens_enabled?(dsl_state) do + validator.(dsl_state) else :ok end diff --git a/lib/ash_authentication/utils.ex b/lib/ash_authentication/utils.ex index 113f9df..b647cf6 100644 --- a/lib/ash_authentication/utils.ex +++ b/lib/ash_authentication/utils.ex @@ -4,11 +4,17 @@ defmodule AshAuthentication.Utils do alias Spark.{Dsl, Dsl.Transformer} @doc """ - Returns true if `falsy` is either `nil` or `false`. + Returns `true` if `falsy` is either `nil` or `false`. """ @spec is_falsy(any) :: Macro.t() defguard is_falsy(falsy) when falsy in [nil, false] + @doc """ + Returns `false` if `truthy` is either `nil` or `false`. + """ + @spec is_truthy(any) :: Macro.t() + defguard is_truthy(truthy) when truthy not in [nil, false] + @doc """ Convert a list of `String.Chars.t` into a sentence. @@ -148,4 +154,79 @@ defmodule AshAuthentication.Utils do do: Map.put(map, field, generator.(map)) def maybe_set_field_lazy(map, _field, _generator), do: map + + @doc """ + Asserts that `resource` is an Ash resource and `extension` is a Spark DSL + extension. + """ + @spec assert_resource_has_extension(Resource.t(), Spark.Dsl.Extension.t()) :: + :ok | {:error, term} + def assert_resource_has_extension(resource, extension) do + with :ok <- assert_is_resource(resource) do + assert_has_extension(resource, extension) + end + end + + @doc """ + Asserts that `module` is actually an Ash resource. + """ + @spec assert_is_resource(Resource.t()) :: :ok | {:error, term} + def assert_is_resource(module) do + with :ok <- assert_is_module(module), + true <- function_exported?(module, :spark_is, 0), + Resource <- module.spark_is() do + :ok + else + _ -> {:error, "Module `#{inspect(module)}` is not an Ash resource"} + end + end + + @doc """ + Asserts that `module` is a Spark DSL extension. + """ + @spec assert_is_extension(Spark.Dsl.Extension.t()) :: :ok | {:error, term} + def assert_is_extension(extension) do + with :ok <- assert_is_module(extension) do + assert_has_behaviour(extension, Spark.Dsl.Extension) + end + end + + @doc """ + Asserts that `module` is actually a module. + """ + @spec assert_is_module(module) :: :ok | {:error, term} + def assert_is_module(module) when is_atom(module) do + case Code.ensure_loaded(module) do + {:module, _module} -> :ok + {:error, _} -> {:error, "Argument `#{inspect(module)}` is not a valid module name"} + end + end + + def assert_is_module(module), + do: {:error, "Argument `#{inspect(module)}` is not a valid module name"} + + @doc """ + Asserts that `module` is extended by `extension`. + """ + @spec assert_has_extension(Resource.t(), Spark.Dsl.Extension.t()) :: :ok | {:error, term} + def assert_has_extension(module, extension) do + if extension in Spark.extensions(module) do + :ok + else + {:error, "Module `#{inspect(module)}` is not extended by `#{inspect(extension)}`"} + end + end + + @doc """ + Asserts that `module` implements `behaviour`. + """ + @spec assert_has_behaviour(module, module) :: :ok | {:error, term} + def assert_has_behaviour(module, behaviour) do + if Spark.implements_behaviour?(module, behaviour) do + :ok + else + {:error, + "Module `#{inspect(module)}` does not implement the `#{inspect(behaviour)}` behaviour"} + end + end end diff --git a/mix.exs b/mix.exs index 5f145ae..7f728b2 100644 --- a/mix.exs +++ b/mix.exs @@ -30,7 +30,7 @@ defmodule AshAuthentication.MixProject do groups_for_modules: [ Extensions: [ AshAuthentication, - AshAuthentication.TokenRevocation, + AshAuthentication.TokenResource, AshAuthentication.UserIdentity ], Strategies: [ diff --git a/mix.lock b/mix.lock index b90747b..6f4f614 100644 --- a/mix.lock +++ b/mix.lock @@ -57,7 +57,7 @@ "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, - "spark": {:hex, :spark, "0.2.12", "03ebab9ed1ecc577c65fd1ae8b88c41d5ba8420b393658616a657d6d0fc2996f", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "22dfba98a9a6ebb5a21d520fa79cf3e67f9f549fff1c6ade55aa6c1d26814463"}, + "spark": {:hex, :spark, "0.2.13", "6a27fa40830cfdeb51ca417cb775c9b9b7583ab3b55c9b70ea9dae04b92a767a", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "8bc0c5e226c2d6be6365a7a9d5bdd394cd9fe4bc0c2074a1ad32b17bc8610c61"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, diff --git a/priv/repo/migrations/20221002235526_migrate_resources1.exs b/priv/repo/migrations/20221002235526_migrate_resources1.exs deleted file mode 100644 index 2c68cd2..0000000 --- a/priv/repo/migrations/20221002235526_migrate_resources1.exs +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Example.Repo.Migrations.MigrateResources1 do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_postgres.generate_migrations` - """ - - use Ecto.Migration - - def up do - create table(:user, primary_key: false) do - add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true - add :username, :citext, null: false - add :hashed_password, :text, null: false - add :created_at, :utc_datetime_usec, null: false, default: fragment("now()") - add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()") - end - end - - def down do - drop table(:user) - end -end diff --git a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs deleted file mode 100644 index d47001a..0000000 --- a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Example.Repo.Migrations.AddTokenRevocationTable do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_postgres.generate_migrations` - """ - - use Ecto.Migration - - def up do - create unique_index(:user, [:username], - name: "user_with_username_username_index" - ) - - create table(:token_revocations, primary_key: false) do - add :expires_at, :utc_datetime, null: false - add :jti, :text, null: false, primary_key: true - end - end - - def down do - drop table(:token_revocations) - - drop_if_exists unique_index(:user, [:username], - name: "user_with_username_username_index" - ) - end -end diff --git a/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs b/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs deleted file mode 100644 index 99d2247..0000000 --- a/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Example.Repo.Migrations.AddConfirmedAtToUserWuthUsername do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_postgres.generate_migrations` - """ - - use Ecto.Migration - - def up do - alter table(:user) do - add :confirmed_at, :utc_datetime_usec - end - end - - def down do - alter table(:user) do - remove :confirmed_at - end - end -end diff --git a/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs b/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs deleted file mode 100644 index d4ac631..0000000 --- a/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Example.Repo.Migrations.RemoveNonNullFromHashedPassword do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_postgres.generate_migrations` - """ - - use Ecto.Migration - - def up do - alter table(:user) do - modify :hashed_password, :text, null: true - end - end - - def down do - alter table(:user) do - modify :hashed_password, :text, null: false - end - end -end diff --git a/priv/repo/migrations/20221109212946_add_user_identitis_table.exs b/priv/repo/migrations/20221109212946_add_user_identitis_table.exs deleted file mode 100644 index efdfb51..0000000 --- a/priv/repo/migrations/20221109212946_add_user_identitis_table.exs +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Example.Repo.Migrations.AddUserIdentitisTable do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_postgres.generate_migrations` - """ - - use Ecto.Migration - - def up do - create table(:user_identities, primary_key: false) do - add :refresh_token, :text - add :access_token_expires_at, :utc_datetime_usec - add :access_token, :text - add :uid, :text, null: false - add :strategy, :text, null: false - add :id, :uuid, null: false, primary_key: true - - add :user_id, - references(:user, - column: :id, - name: "user_identities_user_id_fkey", - type: :uuid, - prefix: "public" - ) - end - - create unique_index(:user_identities, [:strategy, :uid, :user_id], - name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" - ) - end - - def down do - drop_if_exists unique_index(:user_identities, [:strategy, :uid, :user_id], - name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" - ) - - drop constraint(:user_identities, "user_identities_user_id_fkey") - - drop table(:user_identities) - end -end diff --git a/priv/repo/migrations/20221002235524_install_2_extensions.exs b/priv/repo/migrations/20221129214829_install_2_extensions.exs similarity index 100% rename from priv/repo/migrations/20221002235524_install_2_extensions.exs rename to priv/repo/migrations/20221129214829_install_2_extensions.exs diff --git a/priv/repo/migrations/20221129214830_migrate_resources1.exs b/priv/repo/migrations/20221129214830_migrate_resources1.exs new file mode 100644 index 0000000..39995e8 --- /dev/null +++ b/priv/repo/migrations/20221129214830_migrate_resources1.exs @@ -0,0 +1,83 @@ +defmodule Example.Repo.Migrations.MigrateResources1 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:user_identities, primary_key: false) do + add :refresh_token, :text + add :access_token_expires_at, :utc_datetime_usec + add :access_token, :text + add :uid, :text, null: false + add :strategy, :text, null: false + add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true + add :user_id, :uuid + end + + create table(:user, primary_key: false) do + add :confirmed_at, :utc_datetime_usec + add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true + end + + alter table(:user_identities) do + modify :user_id, + references(:user, + column: :id, + prefix: "public", + name: "user_identities_user_id_fkey", + type: :uuid + ) + end + + create unique_index(:user_identities, [:strategy, :uid, :user_id], + name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" + ) + + alter table(:user) do + add :username, :citext, null: false + add :hashed_password, :text + add :created_at, :utc_datetime_usec, null: false, default: fragment("now()") + add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()") + end + + create unique_index(:user, [:username], name: "user_username_index") + + create table(:tokens, primary_key: false) do + add :extra_data, :map + add :purpose, :text, null: false + add :expires_at, :utc_datetime, null: false + add :jti, :text, null: false, primary_key: true + end + end + + def down do + drop table(:tokens) + + drop_if_exists unique_index(:user, [:username], name: "user_username_index") + + alter table(:user) do + remove :updated_at + remove :created_at + remove :hashed_password + remove :username + end + + drop_if_exists unique_index(:user_identities, [:strategy, :uid, :user_id], + name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" + ) + + drop constraint(:user_identities, "user_identities_user_id_fkey") + + alter table(:user_identities) do + modify :user_id, :uuid + end + + drop table(:user) + + drop table(:user_identities) + end +end \ No newline at end of file diff --git a/priv/resource_snapshots/repo/token_revocations/20221020042559.json b/priv/resource_snapshots/repo/tokens/20221129214830.json similarity index 58% rename from priv/resource_snapshots/repo/token_revocations/20221020042559.json rename to priv/resource_snapshots/repo/tokens/20221129214830.json index 642e431..eb3c40e 100644 --- a/priv/resource_snapshots/repo/token_revocations/20221020042559.json +++ b/priv/resource_snapshots/repo/tokens/20221129214830.json @@ -1,5 +1,25 @@ { "attributes": [ + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "extra_data", + "type": "map" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "purpose", + "type": "text" + }, { "allow_nil?": false, "default": "nil", @@ -26,7 +46,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "98092CB4D3ED441847CB38BC76408D77082FA08266FC8224FDF994A08A42CECB", + "hash": "C03B9937AB0DBCCD13164A0772F6A052FB2DAEFBB6F125584F36399531AB5C43", "identities": [], "multitenancy": { "attribute": null, @@ -35,5 +55,5 @@ }, "repo": "Elixir.Example.Repo", "schema": null, - "table": "token_revocations" + "table": "tokens" } \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_with_username/20221107021255.json b/priv/resource_snapshots/repo/user/20221129214830.json similarity index 93% rename from priv/resource_snapshots/repo/user_with_username/20221107021255.json rename to priv/resource_snapshots/repo/user/20221129214830.json index 16f3a7f..629d284 100644 --- a/priv/resource_snapshots/repo/user_with_username/20221107021255.json +++ b/priv/resource_snapshots/repo/user/20221129214830.json @@ -66,11 +66,11 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "7CBDF6DCD8CC589215A49B2F4D2749581BC72D1E4FD27DFBC4460FCDCD6087A6", + "hash": "D72CF1073FC9EAAFA2C59CFAB17301530972DD0A7F030A1DDD006F62F75C1CDA", "identities": [ { "base_filter": null, - "index_name": "user_with_username_username_index", + "index_name": "user_username_index", "keys": [ "username" ], @@ -85,4 +85,4 @@ "repo": "Elixir.Example.Repo", "schema": null, "table": "user" -} +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_identities/20221109212946.json b/priv/resource_snapshots/repo/user_identities/20221129214830.json similarity index 88% rename from priv/resource_snapshots/repo/user_identities/20221109212946.json rename to priv/resource_snapshots/repo/user_identities/20221129214830.json index edfaeb6..412fcd1 100644 --- a/priv/resource_snapshots/repo/user_identities/20221109212946.json +++ b/priv/resource_snapshots/repo/user_identities/20221129214830.json @@ -47,12 +47,12 @@ "primary_key?": false, "references": null, "size": null, - "source": "provider", + "source": "strategy", "type": "text" }, { "allow_nil?": false, - "default": "nil", + "default": "fragment(\"uuid_generate_v4()\")", "generated?": false, "primary_key?": true, "references": null, @@ -90,17 +90,17 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "721BB418378550EA1ABD21F538EFD59A02FEE3E20A96EBCBAF6D2C99E1EC0672", + "hash": "17B4CEEA4E648DF739AD38C1A090B8A97EB9B2A0C9A8F1DFE0B9C3A1842785BE", "identities": [ { "base_filter": null, - "index_name": "user_identities_unique_on_provider_and_uid_and_user_id_index", + "index_name": "user_identities_unique_on_strategy_and_uid_and_user_id_index", "keys": [ - "provider", + "strategy", "uid", "user_id" ], - "name": "unique_on_provider_and_uid_and_user_id" + "name": "unique_on_strategy_and_uid_and_user_id" } ], "multitenancy": { @@ -111,4 +111,4 @@ "repo": "Elixir.Example.Repo", "schema": null, "table": "user_identities" -} +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_with_username/20221002235526.json b/priv/resource_snapshots/repo/user_with_username/20221002235526.json deleted file mode 100644 index ad521ea..0000000 --- a/priv/resource_snapshots/repo/user_with_username/20221002235526.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v4()\")", - "generated?": false, - "primary_key?": true, - "references": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "username", - "type": "citext" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "hashed_password", - "type": "text" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "created_at", - "type": "utc_datetime_usec" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "updated_at", - "type": "utc_datetime_usec" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": false, - "hash": "F14A731ABBE8055A62D20F13B89906CC1EA14CB22BE344DC4E542A17429D55C0", - "identities": [], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Example.Repo", - "schema": null, - "table": "user" -} diff --git a/priv/resource_snapshots/repo/user_with_username/20221020042559.json b/priv/resource_snapshots/repo/user_with_username/20221020042559.json deleted file mode 100644 index e0ed3d2..0000000 --- a/priv/resource_snapshots/repo/user_with_username/20221020042559.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v4()\")", - "generated?": false, - "primary_key?": true, - "references": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "username", - "type": "citext" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "hashed_password", - "type": "text" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "created_at", - "type": "utc_datetime_usec" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "updated_at", - "type": "utc_datetime_usec" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "EFE086DA7BA408EF8829E710CD8F250BE7CDFB956F0AC86EF83726A54210E933", - "identities": [ - { - "base_filter": null, - "index_name": "user_with_username_username_index", - "keys": [ - "username" - ], - "name": "username" - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Example.Repo", - "schema": null, - "table": "user" -} diff --git a/priv/resource_snapshots/repo/user_with_username/20221104032457.json b/priv/resource_snapshots/repo/user_with_username/20221104032457.json deleted file mode 100644 index 1e1ae1c..0000000 --- a/priv/resource_snapshots/repo/user_with_username/20221104032457.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "confirmed_at", - "type": "utc_datetime_usec" - }, - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v4()\")", - "generated?": false, - "primary_key?": true, - "references": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "username", - "type": "citext" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "hashed_password", - "type": "text" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "created_at", - "type": "utc_datetime_usec" - }, - { - "allow_nil?": false, - "default": "fragment(\"now()\")", - "generated?": false, - "primary_key?": false, - "references": null, - "size": null, - "source": "updated_at", - "type": "utc_datetime_usec" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "12E7392CC92EAE2AB421A89BA0C03F0A92D72FD6732BB8CA6914408B1C77F471", - "identities": [ - { - "base_filter": null, - "index_name": "user_with_username_username_index", - "keys": [ - "username" - ], - "name": "username" - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Example.Repo", - "schema": null, - "table": "user" -} diff --git a/test/ash_authentication/jwt/config_test.exs b/test/ash_authentication/jwt/config_test.exs index b5b4410..9269c36 100644 --- a/test/ash_authentication/jwt/config_test.exs +++ b/test/ash_authentication/jwt/config_test.exs @@ -2,7 +2,7 @@ defmodule AshAuthentication.Jwt.ConfigTest do @moduledoc false use ExUnit.Case, async: true use Mimic - alias AshAuthentication.{Jwt.Config, TokenRevocation} + alias AshAuthentication.{Jwt.Config, TokenResource} describe "default_claims/1" do test "it is a token config" do @@ -51,15 +51,15 @@ defmodule AshAuthentication.Jwt.ConfigTest do describe "validate_jti/3" do test "is true when the token has not been revoked" do - TokenRevocation - |> stub(:revoked?, fn _, _ -> false end) + TokenResource + |> stub(:jti_revoked?, fn _, _ -> false end) assert Config.validate_jti("fake jti", nil, Example.User) end test "is false when the token has been revoked" do - TokenRevocation - |> stub(:revoked?, fn _, _ -> true end) + TokenResource + |> stub(:jti_revoked?, fn _, _ -> true end) assert Config.validate_jti("fake jti", nil, Example.User) end diff --git a/test/ash_authentication/jwt_test.exs b/test/ash_authentication/jwt_test.exs index 3dd4d94..b74d5ce 100644 --- a/test/ash_authentication/jwt_test.exs +++ b/test/ash_authentication/jwt_test.exs @@ -69,7 +69,7 @@ defmodule AshAuthentication.JwtTest do test "it is unsuccessful when the token has been revoked" do {:ok, token, _} = build_user() |> Jwt.token_for_user() - AshAuthentication.TokenRevocation.revoke(Example.TokenRevocation, token) + AshAuthentication.TokenResource.revoke(Example.Token, token) assert :error = Jwt.verify(token, :ash_authentication) end diff --git a/test/ash_authentication/plug/helpers_test.exs b/test/ash_authentication/plug/helpers_test.exs index 441c212..2fe2ba4 100644 --- a/test/ash_authentication/plug/helpers_test.exs +++ b/test/ash_authentication/plug/helpers_test.exs @@ -77,7 +77,7 @@ defmodule AshAuthentication.Plug.HelpersTest do |> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}") |> Helpers.revoke_bearer_tokens(:ash_authentication) - assert AshAuthentication.TokenRevocation.revoked?(user.__struct__, jti) + assert AshAuthentication.TokenResource.jti_revoked?(user.__struct__, jti) end end diff --git a/test/ash_authentication/token_resource_test.exs b/test/ash_authentication/token_resource_test.exs new file mode 100644 index 0000000..7ea3e76 --- /dev/null +++ b/test/ash_authentication/token_resource_test.exs @@ -0,0 +1,25 @@ +defmodule AshAuthentication.TokenResourceTest do + @moduledoc false + use DataCase, async: true + alias AshAuthentication.{Jwt, TokenResource} + doctest AshAuthentication.TokenResource + + describe "revoke/2" do + test "it revokes tokens" do + {token, %{"jti" => jti}} = build_token() + refute TokenResource.jti_revoked?(Example.Token, jti) + + assert :ok = TokenResource.revoke(Example.Token, token) + + assert TokenResource.jti_revoked?(Example.Token, jti) + end + end + + def build_token do + {:ok, token, claims} = + build_user() + |> Jwt.token_for_user() + + {token, claims} + end +end diff --git a/test/ash_authentication/token_revocation_test.exs b/test/ash_authentication/token_revocation_test.exs deleted file mode 100644 index 8d2e47e..0000000 --- a/test/ash_authentication/token_revocation_test.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule AshAuthentication.TokenRevocationTest do - @moduledoc false - use DataCase, async: true - import AshAuthentication.TokenRevocation - alias AshAuthentication.{Jwt, TokenRevocation} - doctest AshAuthentication.TokenRevocation - - describe "revoke/2" do - test "it revokes tokens" do - {token, %{"jti" => jti}} = build_token() - refute TokenRevocation.revoked?(Example.TokenRevocation, jti) - - assert :ok = TokenRevocation.revoke(Example.TokenRevocation, token) - - assert TokenRevocation.revoked?(Example.TokenRevocation, jti) - end - end - - def build_token do - {:ok, token, claims} = - build_user() - |> Jwt.token_for_user() - - {token, claims} - end -end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 2e8a79b..ffb6163 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -93,16 +93,4 @@ defmodule DataCase do Ash.Resource.put_metadata(user, field, value) end) end - - @doc "Token revocation factory" - @spec build_token_revocation :: Example.TokenRevocation.t() | no_return - def build_token_revocation do - {:ok, token, _claims} = - build_user() - |> AshAuthentication.Jwt.token_for_user() - - Example.TokenRevocation - |> Ash.Changeset.for_create(:revoke_token, %{token: token}) - |> Example.create!() - end end diff --git a/test/support/example/registry.ex b/test/support/example/registry.ex index a52c074..af64bd2 100644 --- a/test/support/example/registry.ex +++ b/test/support/example/registry.ex @@ -4,7 +4,7 @@ defmodule Example.Registry do entries do entry Example.User - entry Example.TokenRevocation + entry Example.Token entry Example.UserIdentity end end diff --git a/test/support/example/token.ex b/test/support/example/token.ex new file mode 100644 index 0000000..15edb80 --- /dev/null +++ b/test/support/example/token.ex @@ -0,0 +1,15 @@ +defmodule Example.Token do + @moduledoc false + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication.TokenResource] + + postgres do + table("tokens") + repo(Example.Repo) + end + + token do + api Example + end +end diff --git a/test/support/example/token_revocation.ex b/test/support/example/token_revocation.ex deleted file mode 100644 index 33a17ff..0000000 --- a/test/support/example/token_revocation.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Example.TokenRevocation do - @moduledoc false - use Ash.Resource, - data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication.TokenRevocation] - - @type t :: %__MODULE__{ - jti: String.t(), - expires_at: DateTime.t() - } - - actions do - destroy :expire - - update :update do - primary? true - end - end - - postgres do - table("token_revocations") - repo(Example.Repo) - end - - revocation do - api Example - end -end diff --git a/test/support/example/user.ex b/test/support/example/user.ex index 59c31ce..7999a78 100644 --- a/test/support/example/user.ex +++ b/test/support/example/user.ex @@ -102,7 +102,7 @@ defmodule Example.User do tokens do enabled?(true) - revocation_resource(Example.TokenRevocation) + token_resource(Example.Token) end add_ons do diff --git a/test/test_helper.exs b/test/test_helper.exs index f87d548..8f34f69 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -6,5 +6,5 @@ Mimic.copy(AshAuthentication.Strategy.OAuth2.Actions) Mimic.copy(AshAuthentication.Strategy.OAuth2.Plug) Mimic.copy(AshAuthentication.Strategy.Password.Actions) Mimic.copy(AshAuthentication.Strategy.Password.Plug) -Mimic.copy(AshAuthentication.TokenRevocation) +Mimic.copy(AshAuthentication.TokenResource) ExUnit.start(capture_log: true)