lib/auth/token/check.ex

defmodule Rivet.Auth.Token.Check do
  @moduledoc """
  Validate tokens are meeting our requirements
  """
  alias Rivet.Auth
  alias Rivet.Ident
  import Transmogrify.As

  @doc ~S"""
  Checks payload information to verify proper authorization matches

  ```
  iex> {:ok, _token, claims} = Rivet.Auth.Token.Access.jwt("narf", "example.com", 5)
  ...> {:error, %Auth.Domain{} = a} = jwt(%Auth.Domain{hostname: "example.com", token: %{claims: claims}})
  ...> a.log
  "Cannot find identity factor=narf"
  ```

  """
  def jwt(%Auth.Domain{} = auth) do
    # order matters
    # - audience parses the token type
    # - expiration is based on the token type
    # - after which it is worth looking up the subject
    # - signature is different based on audience/type and the subject, so do it last
    auth
    |> enrich_sub_aud()
    |> valid_audience()
    |> valid_expiration()
    |> valid_subject()
  end

  ###########################################################################
  @doc """
  ```
  iex> alias Rivet.Ident.Auth.Domain
  iex> {:error, auth} = enrich_sub_aud(%Auth.Domain{token: %{claims: %{sub: "asdf", aud: "asdf"}}})
  iex> auth.log
  "Cannot parse token.sub=asdf"
  ```
  """
  def enrich_sub_aud(%Auth.Domain{token: %{claims: %{sub: sub, aud: aud}}} = auth) do
    with {:ok, sub} <- expand_claim_value(:sub, sub),
         {:ok, aud} <- expand_claim_value(:aud, aud) do
      %Auth.Domain{auth | token: Map.merge(auth.token, Map.merge(aud, sub))}
    else
      error -> auth_error(auth, error)
    end
  end

  def enrich_sub_aud(auth), do: auth_error(auth, "Unable to check JWT, missing sub/aud")

  defp expand_claim_value(key, val) do
    with [type, value] <- String.split(val, ":", parts: 2) do
      tkey = as_atom!("#{key}_type")
      atype = as_atom!(type)
      {:ok, %{key => value, tkey => atype}}
    else
      _err ->
        {:error, "Cannot parse token.#{key}=#{val}"}
    end
  end

  ###########################################################################
  @doc """
  ```
  iex> alias Rivet.Ident.Auth.Domain
  iex> {:error, auth} = valid_audience(%Auth.Domain{token: %{aud: "asdf"}})
  iex> auth.log
  "Cannot parse token.tok=asdf"
  iex> {:error, auth} = valid_audience(%Auth.Domain{token: %{aud: "key:a.domain"}, hostname: "b.domain"})
  iex> auth.log
  "Token audience does not match: a.domain != b.domain"
  iex> valid_audience({:error, "narf"})
  {:error, "narf"}
  ```
  """
  def valid_audience(%Auth.Domain{token: %{aud: audience}} = auth) do
    case expand_claim_value(:tok, audience) do
      {:ok, %{tok: t_hostname, tok_type: t_type}} ->
        if auth.hostname == t_hostname do
          %Auth.Domain{auth | type: t_type, hostname: t_hostname}
        else
          auth_error(auth, "Token audience does not match: #{t_hostname} != #{auth.hostname}")
        end

      reason ->
        auth_error(auth, reason)
    end
  end

  def valid_audience({:error, _} = pass), do: pass

  ###########################################################################
  @doc """
  ```
  iex> alias Rivet.Ident.Auth.Domain
  iex> now = System.os_time(:second)
  iex> {:error, auth} = valid_expiration(%Auth.Domain{token: %{claims: %{for: %{}, exp: 0}}, type: :acc})
  iex> auth.log
  "Token Expired"
  iex> {:error, auth} = valid_expiration(%Auth.Domain{token: %{claims: %{for: %{}, exp: now+300000}}, type: :acc})
  iex> auth.log
  "Token expiration out of bounds"
  iex> valid_expiration({:error, "narf"})
  {:error, "narf"}
  ```
  """
  def valid_expiration(%Auth.Domain{token: %{claims: %{for: scope} = claims}, type: type} = auth) do
    now = System.os_time(:second)
    expires = claims.exp

    if is_number(expires) and expires > now do
      max_exp = Rivet.Auth.Settings.expire_limit(type, Map.get(scope, :type))
      delta = expires - now

      if delta > max_exp do
        auth_error(auth, "Token expiration out of bounds")
      else
        %Auth.Domain{auth | expires: expires}
      end
    else
      auth_error(auth, "Token Expired")
    end
  end

  def valid_expiration({:error, _} = pass), do: pass

  ###########################################################################
  @doc """
  ```
  iex> alias Rivet.Ident.Auth.Domain
  iex> {:error, auth} = valid_subject(%Auth.Domain{})
  iex> auth.log
  "Unable to process token subject"
  iex> {:error, auth} = valid_subject(%Auth.Domain{token: %{claims: %{sub: "red"}}, type: :acc})
  iex> auth.log
  "Cannot parse token.sub=red"
  iex> {:error, auth} = valid_subject(%Auth.Domain{type: :acc, token: %{sub_type: :cas1, sub: "subject"}})
  iex> auth.log
  "Cannot find identity factor=subject"
  iex> {:error, auth} = valid_subject(%Auth.Domain{token: %{aud_type: :caa1, sub_type: :cas1, sub: "subject"}, type: :val})
  iex> auth.log
  "Cannot find identity factor=subject"
  iex> valid_subject({:error, "narf"})
  {:error, "narf"}
  ```
  """
  def valid_subject(%Auth.Domain{type: :acc, token: %{sub_type: :cas1, sub: sub}} = auth) do
    case Ident.Factor.Lib.get_user(sub) do
      # type: :valtok is correct. The factor is the parent validation token
      # which created this access token, we are just looking through it to the user
      {:ok, %Ident.Factor{type: :valtok} = factor} ->
        {:ok, %Auth.Domain{auth | factor: factor, user: factor.user}}

      {:ok, %Ident.Factor{}} ->
        auth_error(auth, "Provided factor is not a validation token")

      error ->
        auth_error(auth, error)
    end
  end

  def valid_subject(
        %Auth.Domain{token: %{aud_type: :caa1, sub_type: :cas1, sub: sub}, type: :val} = auth
      ) do
    case Ident.Factor.Lib.get_user(sub) do
      {:ok, %Ident.Factor{type: :valtok} = factor} ->
        if is_nil(factor.value) do
          auth_error(auth, "Factor for token is not a validation factor")
        else
          {:ok, %Auth.Domain{auth | factor: factor}}
        end

      error ->
        auth_error(auth, error)
    end
  end

  def valid_subject(%Auth.Domain{token: %{claims: %{sub: sub}}} = auth) do
    auth_error(auth, "Cannot parse token.sub=#{sub}")
  end

  def valid_subject(%Auth.Domain{} = auth),
    do: auth_error(auth, "Unable to process token subject")

  def valid_subject({:error, _} = pass), do: pass

  ###########################################################################
  # DRY
  defp auth_error(auth, {:error, reason}), do: {:error, %Auth.Domain{auth | log: reason}}
  defp auth_error(auth, reason), do: {:error, %Auth.Domain{auth | log: reason}}
end