lib/token.ex

defmodule Magic.Token do
  @moduledoc """
  Provides methods to interact with the DID Token.
  """
  @nbf_grace_period 300

  @typedoc """
  A DID Token generated by a Magic user on the client-side.
  """
  @type did_token :: String.t()

  @typedoc """
  A map of claims made by the DID Token
  """
  @type claim :: %{
          add: String.t(),
          aud: String.t(),
          ext: integer,
          iat: integer,
          iss: String.t(),
          nbf: integer,
          sub: String.t(),
          tid: String.t()
        }

  @typedoc """
  Cryptographic public address of the Magic User.
  """
  @type public_address :: String.t()

  @typedoc """
  Decentralized identifier which encapsulates a unique user ID
  """
  @type issuer :: String.t()

  alias Magic.Utils
  alias Magic.DIDTokenError

  @doc """
    Validates did_token
    
    Returns true or raises an error
  """
  @spec validate!(did_token) :: true
  def validate!(did_token) do
    time = DateTime.utc_now() |> DateTime.to_unix()
    %{proof: proof, claim: claim, message: message} = decode!(did_token)
    rec_address = recover_address(proof, message)

    validate_public_address!(rec_address, did_token)
    validate_claim_fields!(claim)
    validate_claim_ext!(time, claim["ext"])
    validate_claim_nbf!(time, claim["nbf"])
    true
  end

  @doc """
    Validates did_token
    
    Returns :ok or an error tuple
  """
  @spec validate(did_token) :: :ok | {:error, {:did_token_error, String.t()}}
  def validate(did_token) do
    try do
      validate!(did_token)
      :ok
    rescue
      e in DIDTokenError -> {:error, {:did_token_error, e.message}}
    end
  end

  @doc """
    Decodes a DID Token from a Base64 string into a tuple of its individual
    components: proof and claim. This method allows you decode the DID Token
    and inspect the token
    
    Returns A map containing proof, claim and message or raise an error
  """
  @spec decode!(did_token) :: %{proof: String.t(), claim: claim, message: String.t()}
  def decode!(did_token) do
    try do
      [proof, message] = Base.decode64!(did_token) |> Jason.decode!()
      claim = Jason.decode!(message)
      validate_claim_fields!(claim)
      %{proof: proof, claim: claim, message: message}
    rescue
      ArgumentError -> raise DIDTokenError, message: "DID Token is malformed"
      Jason.DecodeError -> raise DIDTokenError, message: "DID Token is malformed"
    end
  end

  @doc """
    Decodes a DID Token from a Base64 string into a tuple of its individual
    components: proof and claim. This method allows you decode the DID Token
    and inspect the token
    
    Returns an :ok tuple with a map containing proof, claim and message or an error tuple
  """
  @spec decode(did_token) ::
          {:ok, %{proof: String.t(), claim: claim, message: String.t()}}
          | {:error, {:did_token_error, String.t()}}
  def decode(did_token) do
    try do
      decoded = decode!(did_token)
      {:ok, decoded}
    rescue
      e in DIDTokenError -> {:error, {:did_token_error, e.message}}
    end
  end

  @doc """
    Parses public_address and extracts issuer
    
    Returns issuer info
  """
  @spec construct_issuer_with_public_address(public_address) :: issuer
  def construct_issuer_with_public_address(public_address) do
    "did:ethr:#{public_address}"
  end

  @doc """
    Parses did_token and extracts issuer
    
    Returns issuer info
  """
  @spec get_issuer(did_token) :: issuer
  def get_issuer(did_token) do
    %{claim: claim} = decode!(did_token)
    claim["iss"]
  end

  @doc """
    Parses did_token and extracts cryptographic public_address
    
    Returns cryptographic public address of the Magic User who generated the supplied DID Token.
  """
  @spec get_public_address(did_token) :: public_address
  def get_public_address(did_token) do
    get_issuer(did_token) |> String.split(":") |> List.last()
  end

  defp recover_address(proof, message) do
    Utils.recover_pubkey(message, proof) |> Utils.pubkey_to_address()
  end

  defp claim_fields() do
    ~w(iat ext iss sub aud nbf tid)
  end

  defp validate_claim_fields!(claim) do
    missing_fields = claim_fields() -- Map.keys(claim)

    if length(missing_fields) > 0 do
      raise DIDTokenError,
        message: "DID Token missing required fields: #{Enum.join(missing_fields, ", ")}"
    end
  end

  defp validate_public_address!(rec_address, did_token) do
    if Utils.hex_to_bin(rec_address) != Utils.hex_to_bin(get_public_address(did_token)) do
      message = "Signature mismatch between 'proof' and 'claim'."
      raise DIDTokenError, message: message
    end
  end

  defp validate_claim_ext!(time, claim_ext) do
    if time > claim_ext do
      message = "Given DID token has expired. Please generate a new one."
      raise DIDTokenError, message: message
    end
  end

  defp apply_nbf_grace_period(claim_nbf) do
    claim_nbf - @nbf_grace_period
  end

  defp validate_claim_nbf!(time, claim_nbf) do
    if time < apply_nbf_grace_period(claim_nbf) do
      message = "Given DID token cannot be used at this time."
      raise DIDTokenError, message: message
    end
  end
end