ash_authentication/lib/ash_authentication.ex
James Harton 8797005175
feat(Ash.PlugHelpers): Support standard actor configuration. (#16)
* improvement(docs): change all references to `actor` to `user`.

The word "actor" has special meaning in the Ash ecosystem.

* chore: format `dev` directory also.

* feat(Ash.PlugHelpers): Support standard actor configuration.

* Adds the `:set_actor` plug which will set the actor to a resource based on the subject name.
* Also includes GraphQL and JSON:API interfaces in the devserver for testing.
2022-10-31 16:43:00 +13:00

243 lines
6.8 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.
This needs to be unique system-wide and if not set will be inferred
from the resource name (ie `MyApp.Accounts.User` will have a subject
name of `user`).
"""
],
api: [
type: {:behaviour, Ash.Api},
doc: """
The name of the Ash API to use to access this resource when registering/authenticating.
""",
required: true
],
get_by_subject_action_name: [
type: :atom,
doc: """
The name of the read action used to retrieve records.
Used internally by `AshAuthentication.subject_to_resource/2`. If the
action doesn't exist, one will be generated for you.
""",
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?
""",
default: false
],
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: """
The resource used to store token revocation 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.
"""
]
]
}
@moduledoc """
AshAuthentication provides a turn-key authentication solution for folks using
[Ash](https://www.ash-hq.org/).
## Usage
This package assumes that you have [Phoenix](https://phoenixframework.org/) and
[Ash](https://ash-hq.org/) installed and configured. See their individual
documentation for details.
Once installed you can easily add support for authentication by configuring one
or more extensions onto your Ash resource:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
end
authentication do
api MyApp.Accounts
end
password_authentication do
identity_field :email
hashed_password_field :hashed_password
end
identities do
identity :unique_email, [:email]
end
end
```
If you plan on providing authentication via the web, then you will need to
define a plug using
[`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html)
which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which
routes incoming authentication requests to the correct provider and provides
callbacks for you to manipulate the conn after success or failure.
## Authentication Providers
Currently the only supported authentication provider is
[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html)
which provides actions for registering and signing in users using an identifier
and a password.
Planned future providers include:
* OAuth 1.0
* OAuth 2.0
* OpenID Connect
## 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, [user]} -> {:ok, user}
_ -> {:error, "Invalid subject"}
end
end
end
end