improvement(TokenResource)!: Move TokenRevocation -> TokenResource.

This paves the way to fix #47.
This commit is contained in:
James Harton 2022-11-30 16:32:13 +13:00
parent e88a516b22
commit 776bd8ea6c
44 changed files with 994 additions and 988 deletions

View file

@ -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)

View file

@ -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
]
]
},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -30,7 +30,7 @@ defmodule AshAuthentication.MixProject do
groups_for_modules: [
Extensions: [
AshAuthentication,
AshAuthentication.TokenRevocation,
AshAuthentication.TokenResource,
AshAuthentication.UserIdentity
],
Strategies: [

View file

@ -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"},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -4,7 +4,7 @@ defmodule Example.Registry do
entries do
entry Example.User
entry Example.TokenRevocation
entry Example.Token
entry Example.UserIdentity
end
end

View file

@ -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

View file

@ -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

View file

@ -102,7 +102,7 @@ defmodule Example.User do
tokens do
enabled?(true)
revocation_resource(Example.TokenRevocation)
token_resource(Example.Token)
end
add_ons do

View file

@ -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)