lib/boruta/oauth/schemas/id_token.ex

defmodule Boruta.Oauth.IdToken do
  @moduledoc """
  OpenID Connect id token schema and utilities
  """

  defmodule Token do
    @moduledoc false

    use Joken.Config

    def token_config, do: %{}
  end

  import Boruta.Config, only: [resource_owners: 0, issuer: 0]

  alias Boruta.Oauth

  @type claims :: %{
          String.t() => String.t() | claims()
        }

  @signature_algorithms [
    RS256: [type: :asymmetric, hash_algorithm: :SHA256, binary_size: 16],
    RS384: [type: :asymmetric, hash_algorithm: :SHA384, binary_size: 24],
    RS512: [type: :asymmetric, hash_algorithm: :SHA512, binary_size: 32],
    HS256: [type: :symmetric, hash_algorithm: :SHA256, binary_size: 16],
    HS384: [type: :symmetric, hash_algorithm: :SHA384, binary_size: 24],
    HS512: [type: :symmetric, hash_algorithm: :SHA512, binary_size: 32]
  ]

  @spec signature_algorithms() :: list(atom())
  def signature_algorithms, do: Keyword.keys(@signature_algorithms)

  @spec signature_type(Oauth.Client.t()) :: signature_type :: atom()
  def signature_type(%Oauth.Client{id_token_signature_alg: signature_alg}),
    do: @signature_algorithms[String.to_atom(signature_alg)][:type]

  @spec hash_alg(Oauth.Client.t()) :: hash_alg :: atom()
  def hash_alg(%Oauth.Client{id_token_signature_alg: signature_alg}),
    do: @signature_algorithms[String.to_atom(signature_alg)][:hash_algorithm]

  @spec hash_binary_size(Oauth.Client.t()) :: binary_size :: integer()
  def hash_binary_size(%Oauth.Client{id_token_signature_alg: signature_alg}),
    do: @signature_algorithms[String.to_atom(signature_alg)][:binary_size]

  @type tokens :: %{
          optional(:code) => %Oauth.Token{
            sub: String.t(),
            client: Oauth.Client.t(),
            inserted_at: DateTime.t(),
            scope: String.t()
          },
          optional(:token) => %Oauth.Token{
            sub: String.t(),
            client: Oauth.Client.t(),
            inserted_at: DateTime.t(),
            scope: String.t()
          },
          optional(:base_token) => %Oauth.Token{
            sub: String.t(),
            client: Oauth.Client.t(),
            inserted_at: DateTime.t(),
            scope: String.t()
          }
        }

  @spec generate(tokens :: tokens(), nonce :: String.t()) :: id_token :: Oauth.Token.t()
  def generate(tokens, nonce) do
    {base_token, payload} = payload(tokens, nonce, %{})

    value = sign(payload, base_token.client)
    %{base_token | type: "id_token", value: value}
  end

  defp payload(%{code: code} = tokens, nonce, acc) do
    tokens
    |> Map.put(:base_token, code)
    |> Map.delete(:code)
    |> payload(nonce, Map.put(acc, "c_hash", hash(code.value, code.client)))
  end

  defp payload(%{token: token} = tokens, nonce, acc) do
    tokens
    |> Map.put(:base_token, token)
    |> Map.delete(:token)
    |> payload(nonce, Map.put(acc, "at_hash", hash(token.value, token.client)))
  end

  defp payload(%{base_token: base_token}, nonce, acc) do
    {base_token, Map.merge(acc, payload(base_token, nonce))}
  end

  defp payload(
         %Oauth.Token{
           sub: sub,
           client: client,
           inserted_at: inserted_at,
           scope: scope,
           resource_owner: resource_owner
         },
         nonce
       ) do
    iat = DateTime.to_unix(inserted_at)

    auth_time =
      case resource_owner.last_login_at do
        nil -> :os.system_time(:seconds)
        last_login_at -> DateTime.to_unix(last_login_at)
      end

    resource_owners().claims(resource_owner, scope)
    |> Map.merge(resource_owner.extra_claims)
    |> Map.put("sub", sub)
    |> Map.put("iss", issuer())
    |> Map.put("aud", client.id)
    |> Map.put("iat", iat)
    |> Map.put("auth_time", auth_time)
    |> Map.put("exp", iat + client.id_token_ttl)
    |> Map.put("nonce", nonce)
  end

  defp sign(
         payload,
         %Oauth.Client{
           id: client_id,
           id_token_signature_alg: signature_alg,
           private_key: private_key,
           secret: secret
         } = client
       ) do
    signer =
      case signature_type(client) do
        :symmetric ->
          Joken.Signer.create(signature_alg, secret)

        :asymmetric ->
          Joken.Signer.create(signature_alg, %{"pem" => private_key}, %{"kid" => client_id})
      end

    with {:ok, token, _payload} <- Token.encode_and_sign(payload, signer) do
      token
    end
  end

  defp hash(string, client) do
    hash_alg(client)
    |> Atom.to_string()
    |> String.downcase()
    |> String.to_atom()
    |> :crypto.hash(string)
    |> binary_part(0, hash_binary_size(client))
    |> Base.url_encode64(padding: false)
  end
end