Skip to content

Commit 2dee552

Browse files
authored
fix: Ensure that installer generated token revocation checking action is correct. (#905)
Unfortunately, when the igniter installer was introduced it generated an action which did not correctly check for token revocation. This has been fixed, along with a verifier warning and an update which fixes the issue.
1 parent 8a729e2 commit 2dee552

File tree

9 files changed

+276
-33
lines changed

9 files changed

+276
-33
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"Marties",
88
"moduledocs",
99
"oidc",
10-
"unguessable"
10+
"unguessable",
11+
"zipper"
1112
]
1213
}

lib/ash_authentication/add_ons/confirmation/transformer.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
2222
with :ok <-
2323
validate_token_generation_enabled(
2424
dsl_state,
25-
"Token generation must be enabled for password resets to work."
25+
"Token generation must be enabled for confirmation to work."
2626
),
2727
:ok <- validate_monitor_fields(dsl_state, strategy),
2828
strategy <- maybe_set_confirm_action_name(strategy),

lib/ash_authentication/token_resource.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ defmodule AshAuthentication.TokenResource do
145145

146146
use Spark.Dsl.Extension,
147147
sections: @dsl,
148-
transformers: [TokenResource.Transformer, TokenResource.Verifier]
148+
transformers: [TokenResource.Transformer],
149+
verifiers: [TokenResource.Verifier]
149150

150151
@doc """
151152
Has the token been revoked?
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
defmodule AshAuthentication.TokenResource.IsRevoked do
22
@moduledoc """
3-
Checks for the existence of a revocation token for the provided tokenrevocation token for the provided token.
3+
Checks for the existence of a revocation token for the provided token revocation token for the provided token.
44
"""
55
use Ash.Resource.Actions.Implementation
6+
alias Ash.Error.Action.InvalidArgument
7+
alias AshAuthentication.{Errors.InvalidToken, Jwt}
68

79
@impl true
8-
def run(input, _, _) do
9-
input.resource
10-
|> Ash.Query.do_filter(purpose: "revocation", jti: input.arguments.jti)
10+
def run(%{resource: resource, arguments: %{jti: jti}}, _, _) when is_binary(jti) do
11+
resource
12+
|> Ash.Query.do_filter(purpose: "revocation", jti: jti)
13+
|> Ash.Query.set_context(%{
14+
private: %{ash_authentication?: true}
15+
})
1116
|> Ash.exists()
1217
end
18+
19+
def run(%{arguments: %{token: token}} = input, opts, context) when is_binary(token) do
20+
case Jwt.peek(token) do
21+
{:ok, %{"jti" => jti}} -> run(%{input | arguments: %{jti: jti}}, opts, context)
22+
{:ok, _} -> {:error, InvalidToken.exception(type: :revocation)}
23+
{:error, reason} -> {:error, reason}
24+
end
25+
end
26+
27+
def run(_input, _, _) do
28+
{:error,
29+
InvalidArgument.exception(
30+
field: :jti,
31+
message: "At least one of `jti` or `token` arguments must be present"
32+
)}
33+
end
1334
end

lib/ash_authentication/token_resource/verifier.ex

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,79 @@ defmodule AshAuthentication.TokenResource.Verifier do
33
The token resource verifier.
44
"""
55

6-
use Spark.Dsl.Transformer
6+
use Spark.Dsl.Verifier
77
require Ash.Expr
8-
alias Spark.{Dsl.Transformer, Error.DslError}
8+
require Logger
9+
alias Spark.{Dsl.Verifier, Error.DslError}
910
import AshAuthentication.Utils
11+
import AshAuthentication.Validations.Action
1012

1113
@doc false
1214
@impl true
13-
@spec after?(any) :: boolean()
14-
def after?(_), do: true
15+
@spec verify(map) :: :ok | {:error, term}
16+
def verify(dsl_state) do
17+
with :ok <- validate_domain_presence(dsl_state) do
18+
maybe_validate_is_revoked_action_arguments(dsl_state)
19+
end
20+
end
1521

16-
@doc false
17-
@impl true
18-
@spec before?(any) :: boolean
19-
def before?(_), do: false
22+
defp maybe_validate_is_revoked_action_arguments(dsl_state) do
23+
case Verifier.get_option(dsl_state, [:token, :revocation], :is_revoked_action_name, :revoked?) do
24+
nil ->
25+
:ok
2026

21-
@doc false
22-
@impl true
23-
@spec after_compile? :: boolean
24-
def after_compile?, do: true
27+
action_name ->
28+
case validate_action_exists(dsl_state, action_name) do
29+
{:ok, action} -> validate_is_revoked_action(dsl_state, action)
30+
{:error, _} -> :ok
31+
end
32+
end
33+
end
2534

26-
@doc false
27-
@impl true
28-
@spec transform(map) ::
29-
:ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
30-
def transform(dsl_state) do
31-
validate_domain_presence(dsl_state)
35+
defp validate_is_revoked_action(dsl_state, action) do
36+
with :ok <- validate_action_argument_option(action, :jti, :allow_nil?, [true]),
37+
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [true]),
38+
:ok <- validate_action_option(action, :returns, [:boolean, Ash.Type.Boolean]) do
39+
:ok
40+
else
41+
{:error, _} ->
42+
Logger.warning("""
43+
Warning while compiling #{inspect(Verifier.get_persisted(dsl_state, :module))}:
44+
45+
The `:jti` and `:token` options to the `#{inspect(action.name)}` action must allow nil values and it must return a `:boolean`.
46+
47+
This was an error in our igniter installer previous to version 4.4.9, which allowed revoked tokens to be reused.
48+
49+
To fix this, run the following command in your shell:
50+
51+
mix ash_authentication.upgrade 4.4.8 4.4.9
52+
53+
Or:
54+
55+
- remove `allow_nil?: false` from these action arguments, and
56+
- ensure that the action returns `:boolean`.
57+
58+
like so:
59+
60+
action :revoked?, :boolean do
61+
description "Returns true if a revocation token is found for the provided token"
62+
argument :token, :string, sensitive?: true
63+
argument :jti, :string, sensitive?: true
64+
65+
run AshAuthentication.TokenResource.IsRevoked
66+
end
67+
""")
68+
69+
:ok
70+
end
3271
end
3372

3473
defp validate_domain_presence(dsl_state) do
35-
with domain when not is_nil(domain) <- Transformer.get_option(dsl_state, [:token], :domain),
74+
with domain when not is_nil(domain) <- Verifier.get_option(dsl_state, [:token], :domain),
3675
:ok <- assert_is_module(domain),
3776
true <- function_exported?(domain, :spark_is, 0),
3877
Ash.Domain <- domain.spark_is() do
39-
{:ok, domain}
78+
:ok
4079
else
4180
nil ->
4281
{:error,

lib/mix/tasks/ash_authentication.install.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,10 @@ if Code.ensure_loaded?(Igniter) do
342342
end
343343
""")
344344
|> Ash.Resource.Igniter.add_action(token_resource, """
345-
action :revoked? do
345+
action :revoked?, :boolean do
346346
description "Returns true if a revocation token is found for the provided token"
347-
argument :token, :string, sensitive?: true, allow_nil?: false
348-
argument :jti, :string, sensitive?: true, allow_nil?: false
347+
argument :token, :string, sensitive?: true
348+
argument :jti, :string, sensitive?: true
349349
350350
run AshAuthentication.TokenResource.IsRevoked
351351
end
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
2+
if Code.ensure_loaded?(Igniter) do
3+
defmodule Mix.Tasks.AshAuthentication.Upgrade do
4+
@moduledoc false
5+
6+
use Igniter.Mix.Task
7+
8+
@impl Igniter.Mix.Task
9+
def info(_argv, _composing_task) do
10+
%Igniter.Mix.Task.Info{
11+
# Groups allow for overlapping arguments for tasks by the same author
12+
# See the generators guide for more.
13+
group: :ash_authentication,
14+
# *other* dependencies to add
15+
# i.e `{:foo, "~> 2.0"}`
16+
adds_deps: [],
17+
# *other* dependencies to add and call their associated installers, if they exist
18+
# i.e `{:foo, "~> 2.0"}`
19+
installs: [],
20+
# An example invocation
21+
# example: __MODULE__.Docs.example(),
22+
example: "example",
23+
# a list of positional arguments, i.e `[:file]`
24+
positional: [:from, :to],
25+
# Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv
26+
# This ensures your option schema includes options from nested tasks
27+
composes: [],
28+
# `OptionParser` schema
29+
schema: [],
30+
# Default values for the options in the `schema`
31+
defaults: [],
32+
# CLI aliases
33+
aliases: [],
34+
# A list of options in the schema that are required
35+
required: []
36+
}
37+
end
38+
39+
@impl Igniter.Mix.Task
40+
def igniter(igniter) do
41+
positional = igniter.args.positional
42+
options = igniter.args.options
43+
44+
upgrades =
45+
%{
46+
"4.4.9" => [&fix_token_is_revoked_action/2]
47+
}
48+
49+
# For each version that requires a change, add it to this map
50+
# Each key is a version that points at a list of functions that take an
51+
# igniter and options (i.e. flags or other custom options).
52+
# See the upgrades guide for more.
53+
Igniter.Upgrades.run(igniter, positional.from, positional.to, upgrades,
54+
custom_opts: options
55+
)
56+
end
57+
58+
def fix_token_is_revoked_action(igniter, _opts) do
59+
case find_all_token_resources(igniter) do
60+
{igniter, []} ->
61+
igniter
62+
63+
{igniter, resources} ->
64+
Enum.reduce(resources, igniter, fn resource, igniter ->
65+
maybe_fix_is_revoked_action(igniter, resource)
66+
end)
67+
end
68+
end
69+
70+
defp find_all_token_resources(igniter) do
71+
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
72+
with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource),
73+
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
74+
{:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :extensions) do
75+
if Igniter.Code.List.list?(zipper) do
76+
match?(
77+
{:ok, _},
78+
Igniter.Code.List.move_to_list_item(
79+
zipper,
80+
&Igniter.Code.Common.nodes_equal?(&1, AshAuthentication.TokenResource)
81+
)
82+
)
83+
else
84+
Igniter.Code.Common.nodes_equal?(zipper, AshAuthentication.TokenResource)
85+
end
86+
end
87+
end)
88+
end
89+
90+
defp maybe_fix_is_revoked_action(igniter, resource) do
91+
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
92+
with {:ok, action_zipper} <- move_to_action(zipper, :action, :revoked?),
93+
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
94+
{:ok, zipper} <- remove_argument_option(zipper, :token, :allow_nil?),
95+
{:ok, zipper} <- remove_argument_option(zipper, :jti, :allow_nil?) do
96+
add_action_return_type(zipper, :boolean)
97+
else
98+
:error -> {:ok, zipper}
99+
end
100+
end)
101+
end
102+
103+
defp move_to_action(zipper, type, name) do
104+
Igniter.Code.Function.move_to_function_call(
105+
zipper,
106+
type,
107+
2,
108+
&Igniter.Code.Function.argument_equals?(&1, 0, name)
109+
)
110+
end
111+
112+
defp add_action_return_type(zipper, type) do
113+
zipper = Sourceror.Zipper.top(zipper)
114+
115+
with {:ok, zipper} <- move_to_action(zipper, :action, :revoked?),
116+
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do
117+
{:ok,
118+
Sourceror.Zipper.insert_left(
119+
zipper,
120+
quote do
121+
unquote(type)
122+
end
123+
)}
124+
end
125+
end
126+
127+
defp remove_argument_option(zipper, argument_name, option) do
128+
with {:ok, zipper} <-
129+
Igniter.Code.Function.move_to_function_call(
130+
zipper,
131+
:argument,
132+
3,
133+
&Igniter.Code.Function.argument_equals?(&1, 0, argument_name)
134+
),
135+
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 2) do
136+
if Igniter.Code.List.find_list_item_index(
137+
zipper,
138+
&Igniter.Code.Tuple.elem_equals?(&1, 0, :do)
139+
) do
140+
Igniter.Code.Common.within(zipper, fn zipper ->
141+
{:ok,
142+
Igniter.Code.Common.remove(
143+
zipper,
144+
&Igniter.Code.Function.function_call?(&1, option, 1)
145+
)}
146+
end)
147+
else
148+
Igniter.Code.List.remove_from_list(
149+
zipper,
150+
&Igniter.Code.Tuple.elem_equals?(&1, 0, option)
151+
)
152+
end
153+
end
154+
end
155+
end
156+
else
157+
defmodule Mix.Tasks.AshAuthentication.Upgrade do
158+
@moduledoc false
159+
160+
use Mix.Task
161+
162+
def run(_argv) do
163+
Mix.shell().error("""
164+
The task 'ash_authentication.upgrade' requires igniter. Please install igniter and try again.
165+
166+
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
167+
""")
168+
169+
exit({:shutdown, 1})
170+
end
171+
end
172+
end

test/mix/tasks/ash_authentication.install_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,10 @@ defmodule Mix.Tasks.AshAuthentication.InstallTest do
189189
prepare(AshAuthentication.TokenResource.GetTokenPreparation)
190190
end
191191
192-
action :revoked? do
192+
action :revoked?, :boolean do
193193
description("Returns true if a revocation token is found for the provided token")
194-
argument(:token, :string, sensitive?: true, allow_nil?: false)
195-
argument(:jti, :string, sensitive?: true, allow_nil?: false)
194+
argument(:token, :string, sensitive?: true)
195+
argument(:jti, :string, sensitive?: true)
196196
197197
run(AshAuthentication.TokenResource.IsRevoked)
198198
end

test/support/example/token.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,14 @@ defmodule Example.Token do
1212

1313
actions do
1414
defaults [:read, :destroy]
15+
16+
action :revoked? do
17+
description "Returns true if a revocation token is found for the provided token"
18+
argument :token, :string, sensitive?: true
19+
argument :jti, :string, sensitive?: true
20+
21+
run AshAuthentication.TokenResource.IsRevoked
22+
returns :boolean
23+
end
1524
end
1625
end

0 commit comments

Comments
 (0)