mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-20 05:13:10 +12:00
211 lines
6.6 KiB
Elixir
211 lines
6.6 KiB
Elixir
defmodule AshAuthentication.Jwt do
|
|
@default_algorithm "HS256"
|
|
@default_lifetime_days 7
|
|
@supported_algorithms Joken.Signer.algorithms()
|
|
import AshAuthentication.Utils, only: [to_sentence: 2]
|
|
|
|
@moduledoc """
|
|
Uses the excellent `joken` hex package to generate and sign Json Web Tokens.
|
|
|
|
## Configuration
|
|
|
|
There are a few things we need to know in order to generate and sign a JWT:
|
|
|
|
* `signing_algorithm` - the crypographic algorithm used to to sign tokens.
|
|
* `token_lifetime` - how long the token is valid for (in hours).
|
|
* `signing_secret` - the secret key used to sign the tokens.
|
|
|
|
These can be configured in your resource's token DSL:
|
|
|
|
```elixir
|
|
defmodule MyApp.Accounts.User do
|
|
# ...
|
|
|
|
authentication do
|
|
tokens do
|
|
token_lifetime 32
|
|
signing_secret fn _, _ ->
|
|
System.fetch_env("TOKEN_SIGNING_SECRET")
|
|
end
|
|
end
|
|
end
|
|
|
|
# ...
|
|
end
|
|
```
|
|
|
|
The signing secret is retrieved using the `AshAuthentication.Secret`
|
|
behaviour, which means that it can be retrieved one of three ways:
|
|
|
|
1. As a string directly in your resource DSL (please don't do this unless you
|
|
know why this is a bad idea!), or
|
|
2. a two-arity anonymous function which returns `{:ok, secret}`, or
|
|
3. the name of a module which implements the `AshAuthentication.Secret`
|
|
behaviour.
|
|
|
|
Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
|
|
|
|
We strongly advise against storing the signing secret in your mix config or
|
|
directly in your resource configuration. We instead suggest you make use of
|
|
[`runtime.exs`](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#configuration)
|
|
and read it from the system environment or other secret store.
|
|
|
|
The default token lifetime is #{@default_lifetime_days * 24} and should be
|
|
specified in integer positive hours.
|
|
"""
|
|
|
|
alias Ash.Resource
|
|
alias AshAuthentication.{Info, Jwt.Config, TokenResource}
|
|
|
|
@typedoc """
|
|
A string likely to contain a valid JWT.
|
|
"""
|
|
@type token :: String.t()
|
|
|
|
@typedoc """
|
|
"claims" are the decoded contents of a JWT. A map of (short) string keys to
|
|
string values.
|
|
"""
|
|
@type claims :: %{required(String.t()) => String.t() | number | boolean | claims}
|
|
|
|
@doc "The default signing algorithm"
|
|
@spec default_algorithm :: String.t()
|
|
def default_algorithm, do: @default_algorithm
|
|
|
|
@doc "Supported signing algorithms"
|
|
@spec supported_algorithms :: [String.t()]
|
|
def supported_algorithms, do: @supported_algorithms
|
|
|
|
@doc "The default token lifetime"
|
|
@spec default_lifetime_hrs :: pos_integer
|
|
def default_lifetime_hrs, do: @default_lifetime_days * 24
|
|
|
|
@doc """
|
|
Given a user, generate a signed JWT for use while authenticating.
|
|
"""
|
|
@spec token_for_user(Resource.record(), extra_claims :: map, options :: keyword) ::
|
|
{:ok, token, claims} | :error
|
|
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
|
|
resource = user.__struct__
|
|
|
|
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
|
|
|
|
subject = AshAuthentication.user_to_subject(user)
|
|
|
|
extra_claims =
|
|
extra_claims
|
|
|> Map.put("sub", subject)
|
|
|
|
{extra_claims, action_opts} =
|
|
case Map.fetch(user.__metadata__, :tenant) do
|
|
{:ok, tenant} ->
|
|
tenant = to_string(tenant)
|
|
{Map.put(extra_claims, "tenant", tenant), [tenant: tenant]}
|
|
|
|
:error ->
|
|
{extra_claims, opts}
|
|
end
|
|
|
|
default_claims = Config.default_claims(resource, action_opts)
|
|
signer = Config.token_signer(resource, opts)
|
|
|
|
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
|
|
:ok <- maybe_store_token(token, resource, user, purpose, action_opts) do
|
|
{:ok, token, claims}
|
|
else
|
|
{:error, _reason} -> :error
|
|
end
|
|
end
|
|
|
|
defp maybe_store_token(token, resource, user, purpose, opts) do
|
|
if Info.authentication_tokens_store_all_tokens?(resource) do
|
|
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
|
|
context_patch = %{
|
|
ash_authentication: %{user: user},
|
|
private: %{ash_authentication?: true}
|
|
}
|
|
|
|
TokenResource.Actions.store_token(
|
|
token_resource,
|
|
%{
|
|
"token" => token,
|
|
"purpose" => to_string(purpose)
|
|
},
|
|
Keyword.update(opts, :context, context_patch, &Map.merge(&1, context_patch))
|
|
)
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Given a token, read it's claims without validating.
|
|
"""
|
|
@spec peek(token) :: {:ok, claims} | {:error, any}
|
|
def peek(token), do: Joken.peek_claims(token)
|
|
|
|
@doc """
|
|
Given a token, verify it's signature and validate it's claims.
|
|
"""
|
|
@spec verify(token, Resource.t() | atom) :: {:ok, claims, Resource.t()} | :error
|
|
def verify(token, otp_app_or_resource) do
|
|
if function_exported?(otp_app_or_resource, :spark_is, 0) &&
|
|
otp_app_or_resource.spark_is() == Resource do
|
|
verify_for_resource(token, otp_app_or_resource)
|
|
else
|
|
verify_for_otp_app(token, otp_app_or_resource)
|
|
end
|
|
end
|
|
|
|
defp verify_for_resource(token, resource) do
|
|
with signer <- Config.token_signer(resource),
|
|
{:ok, claims} <- Joken.verify(token, signer),
|
|
defaults <- Config.default_claims(resource),
|
|
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
|
|
{:ok, claims, resource}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp verify_for_otp_app(token, otp_app) do
|
|
with {:ok, resource} <- token_to_resource(token, otp_app),
|
|
signer <- Config.token_signer(resource),
|
|
{:ok, claims} <- Joken.verify(token, signer),
|
|
defaults <- Config.default_claims(resource),
|
|
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
|
|
{:ok, claims, resource}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Given a token, find a matching resource configuration.
|
|
|
|
## Warning
|
|
|
|
This function *does not* validate the token, so don't rely on it for
|
|
authentication or authorisation.
|
|
"""
|
|
@spec token_to_resource(token, module) :: {:ok, Resource.t()} | :error
|
|
def token_to_resource(token, otp_app) do
|
|
with {:ok, %{"sub" => subject}} <- peek(token),
|
|
%URI{path: subject_name} <- URI.parse(subject) do
|
|
resource_for_subject_name(subject_name, otp_app)
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp resource_for_subject_name(subject_name, otp_app) do
|
|
otp_app
|
|
|> AshAuthentication.authenticated_resources()
|
|
|> Enum.find_value(:error, fn resource ->
|
|
with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource),
|
|
true <- subject_name == to_string(resource_subject_name),
|
|
do: {:ok, resource}
|
|
end)
|
|
end
|
|
end
|