ash_authentication/lib/ash_authentication.ex
James Harton a939dde9b9
feat(PasswordAuthentication): Registration and authentication with local credentials (#4)
This is missing a bunch of features that you probably want to use (eg confirmation, password resets), but it's a pretty good place to put a stake in the sand and say it works.
2022-10-25 11:07:07 +13:00

176 lines
4.7 KiB
Elixir

defmodule AshAuthentication do
import AshAuthentication.Utils, only: [to_sentence: 2]
@authentication %Spark.Dsl.Section{
name: :authentication,
describe: "Configure authentication for this resource",
schema: [
subject_name: [
type: :atom,
doc: """
The subject name is used in generating token claims and in generating authentication routes.
"""
],
api: [
type: {:behaviour, Ash.Api},
doc: """
The name of the Ash API to use to access this resource when registering/authenticating.
"""
],
get_by_subject_action_name: [
type: :atom,
doc: """
The name of the read action used to retrieve the access when calling `AshAuthentication.subject_to_resource/2`.
""",
default: :get_by_subject
]
]
}
@tokens %Spark.Dsl.Section{
name: :tokens,
describe: "Configure JWT settings for this resource",
schema: [
enabled?: [
type: :boolean,
doc: """
Should JWTs be generated by this resource?
"""
],
signing_algorithm: [
type: :string,
doc: """
The algorithm to use for token signing.
Available signing algorithms are; #{to_sentence(Joken.Signer.algorithms(), final: "and")}.
"""
],
token_lifetime: [
type: :pos_integer,
doc: """
How long a token should be valid, in hours.
"""
],
revocation_resource: [
type: {:behaviour, Ash.Resource},
doc: """
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.
"""
]
]
}
@moduledoc """
AshAuthentication
AshAuthentication provides a turn-key authentication solution for folks using
[Ash](https://www.ash-hq.org/).
## Authentication DSL
### Index
#{Spark.Dsl.Extension.doc_index([@authentication])}
### Docs
#{Spark.Dsl.Extension.doc([@authentication])}
## Token DSL
### Index
#{Spark.Dsl.Extension.doc_index([@tokens])}
### Docs
#{Spark.Dsl.Extension.doc([@tokens])}
"""
alias Ash.{Api, Query, Resource}
alias AshAuthentication.Info
alias Spark.Dsl.Extension
use Spark.Dsl.Extension,
sections: [@authentication, @tokens],
transformers: [AshAuthentication.Transformer]
require Ash.Query
@type resource_config :: %{
api: module,
providers: [module],
resource: module,
subject_name: atom
}
@type subject :: String.t()
@doc """
Find all resources which support authentication for a given OTP application.
Returns a map where the key is the authentication provider, and the values are
lists of api/resource pairs.
This is primarily useful for introspection, but also allows us to simplify
token lookup.
"""
@spec authenticated_resources(atom) :: [resource_config]
def authenticated_resources(otp_app) do
otp_app
|> Application.get_env(:ash_apis, [])
|> Stream.flat_map(&Api.Info.resources(&1))
|> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)})
|> Stream.reject(&(elem(&1, 1) == nil))
|> Stream.map(fn {resource, config} ->
Map.put(config, :resource, resource)
end)
|> Enum.to_list()
end
@doc """
Return a subject string for an AshAuthentication resource.
"""
@spec resource_to_subject(Resource.record()) :: subject
def resource_to_subject(record) do
subject_name =
record.__struct__
|> AshAuthentication.Info.authentication_subject_name!()
record.__struct__
|> Resource.Info.primary_key()
|> then(&Map.take(record, &1))
|> then(fn primary_key ->
"#{subject_name}?#{URI.encode_query(primary_key)}"
end)
end
@doc """
Given a subject string, attempt to retrieve a resource.
"""
@spec subject_to_resource(subject | URI.t(), resource_config) ::
{:ok, Resource.record()} | {:error, any}
def subject_to_resource(subject, config) when is_binary(subject),
do: subject |> URI.parse() |> subject_to_resource(config)
def subject_to_resource(%URI{path: subject_name, query: primary_key} = _subject, config)
when is_map(config) do
with ^subject_name <- to_string(config.subject_name),
{:ok, action_name} <- Info.authentication_get_by_subject_action_name(config.resource) do
primary_key =
primary_key
|> URI.decode_query()
|> Enum.to_list()
config.resource
|> Query.for_read(action_name, %{})
|> Query.filter(^primary_key)
|> config.api.read()
|> case do
{:ok, [actor]} -> {:ok, actor}
_ -> {:error, "Invalid subject"}
end
end
end
end