improvement(Confirmation): Store confirmation changes in the token resource.

This prevents possible PII leakage as per #47.
This commit is contained in:
James Harton 2022-12-05 10:05:09 +13:00
parent 776bd8ea6c
commit 5febe36527
21 changed files with 530 additions and 29 deletions

View file

@ -120,20 +120,16 @@ defmodule AshAuthentication.AddOn.Confirmation do
This will generate a token with the `"act"` claim set to the confirmation
action for the strategy, and the `"chg"` claim will contain any changes.
"""
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
@spec confirmation_token(Confirmation.t(), Changeset.t()) ::
{:ok, String.t()} | :error | {:error, any}
def confirmation_token(strategy, changeset) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
claims = %{"act" => strategy.confirm_action_name}
token_lifetime = strategy.token_lifetime * 3600
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
with {:ok, token, _claims} <-
Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime),
:ok <- Confirmation.Actions.store_changes(strategy, token, changeset) do
{:ok, token}
end
end
end

View file

@ -5,8 +5,15 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
Provides the code interface for working with resources via confirmation.
"""
alias Ash.{Changeset, Resource}
alias AshAuthentication.{AddOn.Confirmation, Errors.InvalidToken, Info, Jwt}
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Query, Resource}
alias AshAuthentication.{
AddOn.Confirmation,
Errors.InvalidToken,
Info,
Jwt,
TokenResource
}
@doc """
Attempt to confirm a user.
@ -27,4 +34,70 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
{:error, reason} -> {:error, reason}
end
end
@doc """
Store changes in the tokens resource for later re-use.
"""
@spec store_changes(Confirmation.t(), String.t(), Changeset.t()) :: :ok | {:error, any}
def store_changes(strategy, token, changeset) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
{:ok, api} <- TokenResource.Info.token_api(token_resource),
{:ok, store_changes_action} <-
TokenResource.Info.token_confirmation_store_changes_action_name(token_resource),
{:ok, _token_record} <-
token_resource
|> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(store_changes_action, %{
token: token,
extra_data: changes,
purpose: to_string(strategy.name)
})
|> api.create() do
:ok
else
{:error, reason} ->
{:error, reason}
:error ->
{:error,
AssumptionFailed.exception(
message: "Configuration error storing confirmation token data"
)}
end
end
@doc """
Get changes from the tokens resource for application.
"""
@spec get_changes(Confirmation.t(), String.t()) :: {:ok, map} | :error
def get_changes(strategy, jti) do
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
{:ok, api} <- TokenResource.Info.token_api(token_resource),
{:ok, get_changes_action} <-
TokenResource.Info.token_confirmation_get_changes_action_name(token_resource),
{:ok, [token_record]} <-
token_resource
|> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(get_changes_action, %{"jti" => jti})
|> api.read() do
changes =
strategy.monitor_fields
|> Stream.map(&to_string/1)
|> Stream.map(&{&1, Map.get(token_record.extra_data, &1)})
|> Stream.reject(&is_nil(elem(&1, 1)))
|> Map.new()
{:ok, changes}
else
_ -> :error
end
end
end

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
"""
use Ash.Resource.Change
alias AshAuthentication.Jwt
alias AshAuthentication.{AddOn.Confirmation.Actions, Jwt}
alias Ash.{
Changeset,
@ -30,9 +30,10 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
changeset
|> Changeset.before_action(fn changeset ->
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
{:ok, %{"act" => action, "chg" => changes}, _} <-
{:ok, %{"act" => action, "jti" => jti}, _} <-
Jwt.verify(token, changeset.resource),
true <- to_string(strategy.confirm_action_name) == action do
true <- to_string(strategy.confirm_action_name) == action,
{:ok, changes} <- Actions.get_changes(strategy, jti) do
allowed_changes =
if strategy.inhibit_updates?,
do: Map.take(changes, Enum.map(strategy.monitor_fields, &to_string/1)),

View file

@ -170,6 +170,9 @@ defmodule AshAuthentication.Dsl do
]
end
# The result spec should be changed to `Entity.t` when Spark 0.2.18 goes out.
@doc false
@spec strategy(:confirmation | :oauth2 | :password) :: map
def strategy(:password) do
%Entity{
name: :password,

View file

@ -59,6 +59,26 @@ defmodule AshAuthentication.TokenResource do
default: :revoked?
]
]
},
%Spark.Dsl.Section{
name: :confirmation,
describe: "Configuration options for confirmation tokens",
schema: [
store_changes_action_name: [
type: :atom,
doc: """
The name of the action used to store confirmation changes.
""",
default: :store_confirmation_changes
],
get_changes_action_name: [
type: :atom,
doc: """
The name of the action used to get confirmation changes.
""",
default: :get_confirmation_changes
]
]
}
]
}

View file

@ -25,7 +25,12 @@ defmodule AshAuthentication.TokenResource.Actions do
"""
@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)
resource
|> DataLayer.transaction(fn -> expunge_inside_transaction(resource, opts) end, 5000)
|> case do
{:ok, :ok} -> :ok
{:errore, reason} -> {:error, reason}
end
end
@doc """
@ -110,7 +115,7 @@ defmodule AshAuthentication.TokenResource.Actions do
|> Changeset.for_destroy(expunge_expired_action_name, opts)
|> api.destroy()
|> case do
{:ok, _} -> {:cont, :ok}
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)

View file

@ -0,0 +1,22 @@
defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do
@moduledoc """
Constrains a query to only records which are confirmations that match the jti
argument.
"""
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _, _) do
jti = Query.get_argument(query, :jti)
strategy = query.context.strategy
query
|> Query.filter(purpose: to_string(strategy.name), jti: jti)
|> Query.filter(expires_at >= now())
end
end

View file

@ -0,0 +1,27 @@
defmodule AshAuthentication.TokenResource.StoreConfirmationChangesChange do
@moduledoc """
Populates the JTI based on the token argument.
"""
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, expires_at: expires_at)
else
_ ->
changeset
|> Changeset.add_error([
InvalidArgument.exception(field: :token, message: "is not a valid token")
])
end
end
end

View file

@ -60,6 +60,19 @@ defmodule AshAuthentication.TokenResource.Transformer do
allow_nil?: true,
writable?: true
),
{:ok, dsl_state} <-
maybe_build_attribute(dsl_state, :created_at, :utc_datetime_usec,
allow_nil?: false,
private?: true,
default: &DateTime.utc_now/0
),
{:ok, dsl_state} <-
maybe_build_attribute(dsl_state, :updated_at, :utc_datetime_usec,
allow_nil?: false,
private?: true,
default: &DateTime.utc_now/0,
update_default: &DateTime.utc_now/0
),
:ok <- validate_extra_data_field(dsl_state),
{:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(dsl_state),
{:ok, dsl_state} <-
@ -93,11 +106,104 @@ defmodule AshAuthentication.TokenResource.Transformer do
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 <- validate_is_revoked_action(dsl_state, is_revoked_action_name),
{:ok, get_confirmation_changes_action_name} <-
Info.token_confirmation_get_changes_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
get_confirmation_changes_action_name,
&build_get_confirmation_changes_action(&1, get_confirmation_changes_action_name)
),
:ok <-
validate_get_confirmation_changes_action(
dsl_state,
get_confirmation_changes_action_name
),
{:ok, store_confirmation_changes_action_name} <-
Info.token_confirmation_store_changes_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
store_confirmation_changes_action_name,
&build_store_confirmation_changes_action(&1, store_confirmation_changes_action_name)
),
:ok <-
validate_store_confirmation_changes_action(
dsl_state,
store_confirmation_changes_action_name
) do
{:ok, dsl_state}
end
end
defp build_store_confirmation_changes_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.StoreConfirmationChangesChange
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: action_name,
arguments: arguments,
changes: changes,
accept: [:extra_data, :purpose]
)
end
defp validate_store_confirmation_changes_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_token_argument(action) do
validate_action_has_change(action, TokenResource.StoreConfirmationChangesChange)
end
end
defp build_get_confirmation_changes_action(_dsl_state, action_name) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: :jti,
type: :string,
allow_nil?: false,
sensitive?: true
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: TokenResource.GetConfirmationChangesPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: action_name,
arguments: arguments,
preparations: preparations,
get?: true
)
end
defp validate_get_confirmation_changes_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
:ok <-
validate_action_has_preparation(
action,
TokenResource.GetConfirmationChangesPreparation
) do
validate_field_in_values(action, :type, [:read])
end
end
defp build_read_expired_action(_dsl_state, action_name) do
import Ash.Filter.TemplateHelpers
@ -186,7 +292,6 @@ defmodule AshAuthentication.TokenResource.Transformer do
Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: action_name,
primary?: true,
arguments: arguments,
changes: changes,
accept: [:extra_data]

View file

@ -7,7 +7,7 @@ defmodule AshAuthentication.Transformer do
use Spark.Dsl.Transformer
alias Ash.Resource
alias AshAuthentication.{Info, TokenResource}
alias AshAuthentication.Info
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
@ -78,11 +78,21 @@ defmodule AshAuthentication.Transformer 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
true <- is_atom(resource) do
:ok
else
{:ok, falsy} when is_falsy(falsy) -> :ok
{:error, reason} -> {:error, reason}
{:ok, falsy} when is_falsy(falsy) ->
:ok
{:error, reason} ->
{:error, reason}
false ->
{:error,
DslError.exception(
path: [:authentication, :tokens, :token_resource],
message: "is not a valid module name"
)}
end
end)
end

View file

@ -149,6 +149,10 @@ defmodule AshAuthentication.Utils do
def maybe_set_field(map, _field, _value), do: map
@doc """
Like `maybe_set_field/3` except that the value is lazily generated.
"""
@spec maybe_set_field_lazy(input, any, (input -> value)) :: map when input: map, value: any
def maybe_set_field_lazy(map, field, generator)
when is_falsy(:erlang.map_get(field, map)) and is_function(generator, 1),
do: Map.put(map, field, generator.(map))
@ -177,7 +181,8 @@ defmodule AshAuthentication.Utils do
Resource <- module.spark_is() do
:ok
else
_ -> {:error, "Module `#{inspect(module)}` is not an Ash resource"}
_ ->
{:error, "Module `#{inspect(module)}` is not an Ash resource"}
end
end

View file

@ -1,7 +1,7 @@
%{
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"ash": {:hex, :ash, "2.4.11", "a9fd2616b4ade692361140a0501b03533e60ad9a7a67615041b697daf802efd5", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.2 and >= 0.2.10", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0da0b4a8da4247cc14e2ba139c6b8175e33416fa962faba80725bdbf12da355e"},
"ash": {:hex, :ash, "2.4.16", "249d2490ddd02448910b70ad9ff3abb827a0ac64c678e0209fc9d0a75609350a", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ecd41ef4f7167d6f9e102f20530e38f4771c52b19fdcba06f733224ea28db1a8"},
"ash_graphql": {:hex, :ash_graphql, "0.22.0", "3817a55e4571a8317b2ef5217635e9b188bb85c273f8101ee488897901863078", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, "~> 2.4", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "775ab8898aed4b762a08c9d49bfbff5c41e31643b7262253ed5ff911d85c0ee5"},
"ash_json_api": {:hex, :ash_json_api, "0.30.1", "54e60c4862eee35ed8a9a925e5c99be2b80e36a2507355bdb0f0974defe82a8d", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b8b4827aa02de75a9a48d2941e813947da46b7dbfdd84cd20959dbaec103f830"},
"ash_postgres": {:hex, :ash_postgres, "1.1.2", "1afd8ac43e68de8a92d22c8e8f3c36552665bc1c91dcdc4e5945d4e88c606bbe", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0877e0d5e7ff36b7c6b0f22ce95ed8980695a0ba309cba77c60cf911c9678854"},

View file

@ -47,6 +47,8 @@ defmodule Example.Repo.Migrations.MigrateResources1 do
create unique_index(:user, [:username], name: "user_username_index")
create table(:tokens, primary_key: false) do
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
add :extra_data, :map
add :purpose, :text, null: false
add :expires_at, :utc_datetime, null: false

View file

@ -1,5 +1,25 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
@ -46,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "C03B9937AB0DBCCD13164A0772F6A052FB2DAEFBB6F125584F36399531AB5C43",
"hash": "8E544287AF1E39AA15A68F9B7A98AA11E0E089030F01748B7C576CB75BF05A72",
"identities": [],
"multitenancy": {
"attribute": null,

View file

@ -2,8 +2,10 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
@moduledoc false
use DataCase, async: true
import Ecto.Query
alias Ash.Changeset
alias AshAuthentication.{AddOn.Confirmation, AddOn.Confirmation.Actions, Info}
alias AshAuthentication.{AddOn.Confirmation, AddOn.Confirmation.Actions, Info, Jwt}
describe "confirm/2" do
test "it returns an error when there is no corresponding user" do
@ -50,4 +52,60 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
1.0
end
end
describe "store_changes/3" do
test "it stores only the changes in the strategy's monitored fields" do
{:ok, strategy} = Info.strategy(Example.User, :confirm)
user = build_user()
changeset =
user
|> Changeset.for_update(:update, %{
"username" => username(),
"hashed_password" => password()
})
{:ok, token, _} = Jwt.token_for_user(user)
:ok = Actions.store_changes(strategy, token, changeset)
query =
from(t in Example.Token,
where: t.purpose == "confirm",
order_by: [desc: t.created_at],
limit: 1
)
token = Example.Repo.one(query)
assert Map.has_key?(token.extra_data, "username")
refute Map.has_key?(token.extra_data, "hashed_password")
end
end
describe "get_changes/2" do
test "it retrieves only the changes in the strategy's monitored fields" do
{:ok, strategy} = Info.strategy(Example.User, :confirm)
user = build_user()
{:ok, _token, %{"jti" => jti, "exp" => exp}} = Jwt.token_for_user(user)
%Example.Token{}
|> Ecto.Changeset.cast(
%{
"jti" => jti,
"expires_at" => DateTime.from_unix!(exp),
"purpose" => "confirm",
"extra_data" => %{"username" => username(), "hashed_password" => password()}
},
~w[jti expires_at purpose extra_data]a
)
|> Example.Repo.insert!()
{:ok, changes} = Actions.get_changes(strategy, jti)
assert Map.has_key?(changes, "username")
refute Map.has_key?(changes, "hashed_password")
end
end
end

View file

@ -18,7 +18,33 @@ defmodule AshAuthentication.AddOn.ConfirmationTest do
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
assert {:ok, claims} = Jwt.peek(token)
assert claims["act"] == to_string(strategy.confirm_action_name)
assert claims["chg"] == %{"username" => new_username}
end
test "it stores changes in the token resource" do
{:ok, strategy} = Info.strategy(Example.User, :confirm)
user = build_user()
new_username = username()
changeset = Changeset.for_update(user, :update, %{"username" => new_username})
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
assert {:ok, claims} = Jwt.peek(token)
assert {:ok, changes} = Confirmation.Actions.get_changes(strategy, claims["jti"])
assert [{"username", new_username}] == Enum.to_list(changes)
end
test "it does not store the changes in the confirmation token" do
{:ok, strategy} = Info.strategy(Example.User, :confirm)
user = build_user()
new_username = username()
changeset = Changeset.for_update(user, :update, %{"username" => new_username})
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
assert {:ok, claims} = Jwt.peek(token)
refute Map.has_key?(claims, "chg")
end
end

View file

@ -0,0 +1,126 @@
defmodule AshAuthentication.TokenResource.ActionsTest do
@moduledoc false
use DataCase, async: true
alias AshAuthentication.{Jwt, TokenResource.Actions}
describe "read_expired/1..2" do
test "it errors when passed a non-token resource" do
assert {:error, _} = Actions.read_expired(Example.User)
end
test "it returns all expired token records" do
user = build_user()
now =
DateTime.utc_now()
|> DateTime.to_unix()
jtis =
-3..-1
|> Enum.concat(1..3)
|> Enum.map(fn i ->
{:ok, token, %{"jti" => jti}} = Jwt.token_for_user(user, %{"exp" => now + i})
:ok = Actions.revoke(Example.Token, token)
{jti, i}
end)
|> Enum.filter(&(elem(&1, 1) <= 0))
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
assert {:ok, records} = Actions.read_expired(Example.Token)
record_jtis =
records
|> Enum.map(& &1.jti)
|> Enum.sort()
assert record_jtis == jtis
end
end
describe "expunge_expired/1..2" do
test "it removes any expired tokens" do
user = build_user()
now =
DateTime.utc_now()
|> DateTime.to_unix()
10..1
|> Enum.each(fn i ->
{:ok, token, _} = Jwt.token_for_user(user, %{"exp" => now - i})
:ok = Actions.revoke(Example.Token, token)
end)
assert {:ok, expired} = Actions.read_expired(Example.Token)
assert length(expired) == 10
assert :ok = Actions.expunge_expired(Example.Token)
assert {:ok, []} = Actions.read_expired(Example.Token)
end
test "it doesn't remove any unexpired tokens" do
user = build_user()
now =
DateTime.utc_now()
|> DateTime.to_unix()
10..19
|> Enum.each(fn i ->
{:ok, token, _} = Jwt.token_for_user(user, %{"exp" => now + i})
:ok = Actions.revoke(Example.Token, token)
end)
assert :ok = Actions.expunge_expired(Example.Token)
import Ecto.Query
query = from(t in Example.Token, where: t.purpose == "revocation")
tokens = Example.Repo.all(query)
assert length(tokens) == 10
end
end
describe "token_revoked?" do
test "it returns true when the token has been revoked" do
user = build_user()
token = user.__metadata__.token
refute Actions.token_revoked?(Example.Token, token)
:ok = Actions.revoke(Example.Token, token)
assert Actions.token_revoked?(Example.Token, token)
end
end
describe "jti_revoked?" do
test "it returns true when the token jti has been revoked" do
user = build_user()
token = user.__metadata__.token
{:ok, %{"jti" => jti}} = Jwt.peek(token)
refute Actions.jti_revoked?(Example.Token, jti)
:ok = Actions.revoke(Example.Token, token)
assert Actions.jti_revoked?(Example.Token, jti)
end
end
describe "valid_jti?" do
test "it returns true when the token jti has not revoked" do
user = build_user()
token = user.__metadata__.token
{:ok, %{"jti" => jti}} = Jwt.peek(token)
assert Actions.valid_jti?(Example.Token, jti)
:ok = Actions.revoke(Example.Token, token)
refute Actions.valid_jti?(Example.Token, jti)
end
end
end

View file

@ -8,10 +8,12 @@ defmodule AshAuthentication.TokenResourceTest do
test "it revokes tokens" do
{token, %{"jti" => jti}} = build_token()
refute TokenResource.jti_revoked?(Example.Token, jti)
refute TokenResource.token_revoked?(Example.Token, token)
assert :ok = TokenResource.revoke(Example.Token, token)
assert TokenResource.jti_revoked?(Example.Token, jti)
assert TokenResource.token_revoked?(Example.Token, token)
end
end