Packages
ash_authentication
4.9.5
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/jwt.ex
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.
"""
require Logger
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 \\ [], context \\ %{}) 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)
action_opts =
case Map.fetch(user.__metadata__, :tenant) do
{:ok, tenant} ->
Keyword.put(opts, :tenant, tenant)
:error ->
opts
end
default_claims = Config.default_claims(resource, action_opts)
signer = Config.token_signer(resource, opts, context)
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} ->
Logger.error("Failed to generate token for user: #{inspect(reason, pretty: true)}")
:error
end
end
@doc """
Given a resource, generate a signed JWT with a set of claims.
"""
@spec token_for_resource(Resource.t(), extra_claims :: map, options :: keyword) ::
{:ok, token, claims} | :error
def token_for_resource(resource, extra_claims, opts \\ [], context) do
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource, opts, context)
subject =
Info.authentication_subject_name!(resource)
extra_claims =
extra_claims
|> Map.put("sub", subject)
with {:ok, token, claims} <-
Joken.generate_and_sign(default_claims, extra_claims, signer),
:ok <- maybe_store_token(token, resource, nil, purpose, opts) do
{:ok, token, claims}
else
{:error, reason} ->
Logger.error(
"Failed to generate token for #{inspect(resource)}: #{inspect(reason, pretty: true)}"
)
: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, opts :: Keyword.t(), context :: map()) ::
{:ok, claims, Resource.t()} | :error
def verify(token, otp_app_or_resource, opts \\ [], context \\ %{}) 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, opts, context)
else
verify_for_otp_app(token, otp_app_or_resource, opts, context)
end
end
defp verify_for_resource(token, resource, opts, context) do
with signer <- Config.token_signer(resource, [], context),
{:ok, claims} <- Joken.verify(token, signer),
defaults <- Config.default_claims(resource, opts),
{:ok, claims} <- Joken.validate(defaults, claims, resource) do
{:ok, claims, resource}
else
_ -> :error
end
end
defp verify_for_otp_app(token, otp_app, opts, context) do
with {:ok, resource} <- token_to_resource(token, otp_app),
signer <- Config.token_signer(resource, [], context),
{:ok, claims} <- Joken.verify(token, signer),
defaults <- Config.default_claims(resource, opts),
{: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