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 enabled? true 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 :: %{}, 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 TokenResource.Actions.store_token( token_resource, %{ "token" => token, "purpose" => to_string(purpose) }, Keyword.put(opts, :context, %{ ash_authentication: %{ user: user } }) ) 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