Packages
ash_authentication
5.0.0-rc.7
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/ash_authentication/token_resource/actions.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.TokenResource.Actions do
@moduledoc """
The code interface for interacting with the token resource.
"""
alias Ash.{Changeset, Query, Resource}
alias AshAuthentication.{Errors.InvalidToken, Jwt, TokenResource, TokenResource.Info}
import AshAuthentication.Utils
require Logger
@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, domain} <- Info.token_domain(resource),
{:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource) do
resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.for_read(read_expired_action_name, opts)
|> Ash.read(domain: domain)
end
end
@doc """
Remove all expired records.
"""
@spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any}
def expunge_expired(resource, opts \\ []) do
expunge_expired_action_name = Info.token_expunge_expired_action_name!(resource)
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource) do
opts =
opts
|> Keyword.put_new_lazy(:domain, fn -> Info.token_domain!(resource) end)
authorize_with =
if Ash.DataLayer.data_layer_can?(resource, :expr_error) do
:error
else
:filter
end
resource
|> Query.new()
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.for_read(read_expired_action_name, %{}, opts)
|> Ash.bulk_destroy(
expunge_expired_action_name,
%{},
opts
|> Keyword.update(
:context,
%{private: %{ash_authentication?: true}},
&Ash.Helpers.deep_merge_maps(&1, %{private: %{ash_authentication?: true}})
)
|> Keyword.merge(
strategy: [:atomic, :atomic_batches, :stream],
return_errors?: true,
notify?: true,
return_records?: false,
return_notifications?: false,
authorize_with: authorize_with
)
)
|> case do
%{status: :success} -> :ok
%{errors: errors} -> {:error, Ash.Error.to_class(errors)}
end
end
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, domain} <- Info.token_domain(resource),
{:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
resource
|> Ash.ActionInput.for_action(
is_revoked_action_name,
%{"token" => token},
Keyword.put(opts, :domain, domain)
)
|> Ash.ActionInput.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Ash.run_action()
|> case do
{:ok, value} ->
value
{:error, error} ->
Logger.error("""
Error while checking if token is revoked.
We must assume that it is revoked for security purposes.
#{Exception.format(:error, error)}
""")
true
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, domain} <- Info.token_domain(resource),
{:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
resource
|> Ash.ActionInput.for_action(
is_revoked_action_name,
%{"jti" => jti},
Keyword.take(Keyword.put(opts, :domain, domain), [
:actor,
:authorize?,
:context,
:tenant,
:tracer,
:domain
])
)
|> Ash.ActionInput.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Ash.run_action()
|> case do
{:ok, value} ->
value
{:error, error} ->
Logger.error("""
Error while checking if token is revoked.
We must assume that it is revoked for security purposes.
#{Exception.format(:error, error)}
""")
true
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 revocation
record.
## Options
* `:store_all_tokens?` — describes whether the token was generated by an
authentication resource with `store_all_tokens?` enabled. When provided,
the revocation is performed atomically so that concurrent revocations of
the same token cannot both succeed:
* `true` — the token row is expected to exist; it is locked with
`SELECT … FOR UPDATE` and its purpose flipped to `"revocation"`.
Returns `{:error, _}` if the row is already a revocation record.
* `false` — the token row is expected not to exist; a plain insert is
performed. A concurrent duplicate results in a primary key conflict
which is surfaced as `{:error, _}`.
If omitted, the legacy upsert behaviour is used. This path is NOT safe
against concurrent duplicate revocations and is retained only for
backwards compatibility.
"""
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
def revoke(resource, token, opts \\ []) do
{store_all_tokens?, opts} = Keyword.pop(opts, :store_all_tokens?)
case store_all_tokens? do
true -> revoke_locked(resource, token, opts)
false -> revoke_atomic_insert(resource, token, opts)
nil -> revoke_legacy_upsert(resource, token, opts)
end
end
defp revoke_atomic_insert(resource, token, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, insert_action_name} <-
Info.token_revocation_revoke_token_insert_action_name(resource) do
resource
|> Changeset.new()
|> Changeset.set_context(%{private: %{ash_authentication?: true}})
|> Changeset.for_create(insert_action_name, %{"token" => token}, opts)
|> Ash.create(domain: domain)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, translate_jti_conflict(reason)}
end
end
end
defp revoke_locked(resource, token, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, %{"jti" => jti}} <- Jwt.peek(token),
{:ok, domain} <- Info.token_domain(resource),
lookup_opts =
opts
|> Keyword.take([:actor, :authorize?, :context, :tenant, :tracer])
|> Keyword.merge(
domain: domain,
authorize?: false,
error?: false
)
|> maybe_lock_opt(resource, :for_update),
{:ok, existing} <- Ash.get(resource, [jti: jti], lookup_opts) do
case existing && existing.purpose do
"revocation" ->
{:error,
InvalidToken.exception(type: :revocation, reason: "token has already been revoked")}
nil ->
# Row missing despite the caller expecting it to exist (e.g. stored
# token was deleted, or config changed). Fall back to atomic insert
# so the race-safety guarantee is preserved.
revoke_atomic_insert(resource, token, opts)
_ ->
revoke_legacy_upsert(resource, token, opts)
end
end
end
defp revoke_legacy_upsert(resource, token, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, revoke_token_action_name} <-
Info.token_revocation_revoke_token_action_name(resource) do
resource
|> Changeset.new()
|> Changeset.set_context(%{private: %{ash_authentication?: true}})
|> Changeset.for_create(
revoke_token_action_name,
%{"token" => token},
Keyword.merge(opts, upsert?: true)
)
|> Ash.create(domain: domain)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
@doc """
Revoke a token by JTI.
If you have the token, you should use `revoke/2` instead.
Accepts the same `:store_all_tokens?` option as `revoke/3`.
"""
@spec revoke_jti(Resource.t(), String.t(), String.t(), keyword) ::
:ok | {:error, any}
def revoke_jti(resource, jti, subject, opts \\ []) do
{store_all_tokens?, opts} = Keyword.pop(opts, :store_all_tokens?)
case store_all_tokens? do
true -> revoke_jti_locked(resource, jti, subject, opts)
false -> revoke_jti_atomic_insert(resource, jti, subject, opts)
nil -> revoke_jti_legacy_upsert(resource, jti, subject, opts)
end
end
defp revoke_jti_atomic_insert(resource, jti, subject, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, insert_action_name} <-
Info.token_revocation_revoke_jti_insert_action_name(resource) do
resource
|> Changeset.new()
|> Changeset.set_context(%{private: %{ash_authentication?: true}})
|> Changeset.for_create(insert_action_name, %{"jti" => jti, "subject" => subject}, opts)
|> Ash.create(domain: domain)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, translate_jti_conflict(reason)}
end
end
end
defp revoke_jti_locked(resource, jti, subject, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
lookup_opts =
opts
|> Keyword.take([:actor, :authorize?, :context, :tenant, :tracer])
|> Keyword.merge(
domain: domain,
authorize?: false,
error?: false
)
|> maybe_lock_opt(resource, :for_update),
{:ok, existing} <- Ash.get(resource, [jti: jti], lookup_opts) do
case existing && existing.purpose do
"revocation" ->
{:error,
InvalidToken.exception(type: :revocation, reason: "token has already been revoked")}
nil ->
revoke_jti_atomic_insert(resource, jti, subject, opts)
_ ->
revoke_jti_legacy_upsert(resource, jti, subject, opts)
end
end
end
defp revoke_jti_legacy_upsert(resource, jti, subject, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, revoke_jti_action_name} <-
Info.token_revocation_revoke_jti_action_name(resource) do
resource
|> Changeset.new()
|> Changeset.set_context(%{private: %{ash_authentication?: true}})
|> Changeset.for_create(
revoke_jti_action_name,
%{"jti" => jti, "subject" => subject},
Keyword.merge(opts, upsert?: true)
)
|> Ash.create(domain: domain)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp translate_jti_conflict(%Ash.Error.Invalid{errors: errors} = error) do
if Enum.any?(errors, &jti_unique_conflict?/1) do
InvalidToken.exception(type: :revocation, reason: "token has already been revoked")
else
error
end
end
defp translate_jti_conflict(error), do: error
defp jti_unique_conflict?(%Ash.Error.Changes.InvalidAttribute{field: :jti, private_vars: vars}),
do: Keyword.get(vars, :constraint_type) == :unique
defp jti_unique_conflict?(_), do: false
@doc """
Store a token.
Stores a token for any purpose.
"""
@spec store_token(Resource.t(), map, keyword) :: :ok | {:error, any}
def store_token(resource, params, opts \\ []) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, store_token_action_name} <- Info.token_store_token_action_name(resource) do
resource
|> Changeset.new()
|> Changeset.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Changeset.for_create(
store_token_action_name,
params,
Keyword.merge(opts, upsert?: true)
)
|> Ash.create(domain: domain)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
@doc """
Retrieve a token by token or JTI optionally filtering by purpose.
"""
@spec get_token(Resource.t(), map, keyword) :: {:ok, [Resource.record()]} | {:error, any}
def get_token(resource, params, opts \\ []) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, domain} <- Info.token_domain(resource),
{:ok, get_token_action_name} <- Info.token_get_token_action_name(resource) do
resource
|> Query.new()
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.for_read(
get_token_action_name,
params,
Keyword.take(Keyword.put(opts, :domain, domain), [
:actor,
:authorize?,
:tenant,
:tracer,
:domain
])
)
|> Ash.read()
end
end
end