lib/ueberauth/strategy/oidcc.ex

defmodule Ueberauth.Strategy.Oidcc do
  @moduledoc """
  OIDC Strategy for Ueberauth.
  """

  use Ueberauth.Strategy

  alias Ueberauth.Auth.Credentials
  alias Ueberauth.Auth.Extra
  alias Ueberauth.Auth.Info

  @doc """
  Handles the initial authentication request.
  """
  def handle_request!(conn) do
    opts = get_options!(conn)

    params =
      params_from_conn(conn, %{
        response_type: opts.response_type,
        redirect_uri: opts.redirect_uri,
        scopes: opts.scopes
      })

    maybe_uri =
      if authorization_endpoint = Map.get(opts, :authorization_endpoint) do
        params =
          params
          |> Map.put(:client_id, opts.client_id)
          |> Map.put(:scope, Enum.join(params.scopes, " "))
          |> Map.delete(:scopes)
          |> Map.merge(Map.get(opts, :authorization_params, %{}))

        query = URI.encode_query(params)
        {:ok, "#{authorization_endpoint}?#{query}"}
      else
        params =
          if redirect_params = Map.get(opts, :authorization_params) do
            Map.put(params, :url_extension, to_url_extension(redirect_params))
          else
            params
          end

        case opts do
          %{issuer: _, client_id: _} ->
            opts.module.create_redirect_url(opts.issuer, opts.client_id, :unauthenticated, params)

          %{client_id: _} ->
            {:error, :missing_issuer}

          %{} ->
            {:error, :missing_client_id}
        end
      end

    case maybe_uri do
      {:ok, uri} ->
        redirect!(conn, IO.iodata_to_binary(uri))

      {:error, reason} ->
        set_error!(
          conn,
          "create_redirect_url",
          reason
        )
    end
  end

  @doc """
  Handles the callback from the oidc provider.
  """
  def handle_callback!(conn) do
    opts = get_options!(conn)

    with code when is_binary(code) <- conn.params["code"],
         {:ok, token} <-
           opts.module.retrieve_token(
             code,
             opts.issuer,
             opts.client_id,
             opts.client_secret,
             opts
           ) do
      conn
      |> put_private(:ueberauth_oidcc_opts, opts)
      |> put_private(:ueberauth_oidcc_token, token)
      |> maybe_put_userinfo()
    else
      nil ->
        set_error!(conn, "code", "Query string does not contain field 'code'")

      {:error, reason} ->
        set_error!(conn, "retrieve_token", reason)
    end
  end

  defp params_from_conn(conn, params) do
    []
    |> with_state_param(conn)
    |> Map.new()
    |> Map.merge(params)
  end

  defp maybe_put_userinfo(conn) do
    opts = conn.private.ueberauth_oidcc_opts

    with true <- Map.get(opts, :userinfo, false),
         {:ok, userinfo} <-
           opts.module.retrieve_userinfo(
             conn.private.ueberauth_oidcc_token,
             opts.issuer,
             opts.client_id,
             opts.client_secret,
             opts
           ) do
      put_private(conn, :ueberauth_oidcc_userinfo, userinfo)
    else
      false ->
        conn

      {:error, reason} ->
        set_error!(conn, "retrieve_userinfo", reason)
    end
  end

  @doc false
  def handle_cleanup!(conn) do
    conn
    |> put_private(:ueberauth_oidcc_opts, nil)
    |> put_private(:ueberauth_oidcc_token, nil)
    |> put_private(:ueberauth_oidcc_userinfo, nil)
  end

  @doc """
  Returns the configured uid field from the claims.
  """
  def uid(conn) do
    opts = conn.private.ueberauth_oidcc_opts
    uid_field = Map.get(opts, :uid_field, "sub")

    case conn.private do
      %{ueberauth_oidcc_userinfo: %{^uid_field => uid}} ->
        uid

      %{ueberauth_oidcc_token: token} ->
        Map.get(token.id.claims, uid_field)
    end
  end

  @doc """
  Returns the credentials from the oidc response.

  `other` includes `id_token`
  """
  def credentials(conn) do
    token = conn.private.ueberauth_oidcc_token

    refresh_token =
      case token.refresh do
        %{token: token} -> token
        _ -> nil
      end

    expires_at =
      case token.access.expires do
        e when is_integer(e) ->
          System.system_time(:second) + e

        _ ->
          nil
      end

    %Credentials{
      token: token.access.token,
      refresh_token: refresh_token,
      token_type: "Bearer",
      expires: !!token.access.expires,
      expires_at: expires_at,
      scopes: token.scope,
      other: %{
        id_token: token.id.token
      }
    }
  end

  @doc """
  Returns an `Ueberauth.Auth.Extra` struct containing the claims and userinfo response.
  """
  def extra(conn) do
    %Extra{
      raw_info: %{
        opts: conn.private.ueberauth_oidcc_opts,
        claims: conn.private.ueberauth_oidcc_token.id.claims,
        userinfo: conn.private[:ueberauth_oidcc_userinfo]
      }
    }
  end

  @doc """
  Returns a `Ueberauth.Auth.Info` struct populated with the data returned from
  the userinfo endpoint.

  This information is also included in the `Ueberauth.Auth.Credentials` struct.
  """
  def info(conn) do
    userinfo = conn.private[:ueberauth_oidcc_userinfo] || %{}

    claims =
      Map.merge(conn.private.ueberauth_oidcc_token.id.claims, userinfo)

    urls =
      %{}
      |> add_optional_url(:profile, claims["profile"])
      |> add_optional_url(:website, claims["website"])

    # https://openid.net/specs/openid-connect-core-1_0.html#Claims
    %Info{
      name: claims["name"],
      first_name: claims["given_name"],
      last_name: claims["family_name"],
      nickname: claims["nickname"],
      email: claims["email"],
      # address claim is a JSON blob
      location: nil,
      description: nil,
      image: claims["picture"],
      phone: claims["phone_number"],
      birthday: claims["birthdate"],
      urls: urls
    }
  end

  defp set_error!(conn, key, message) do
    set_errors!(conn, [error(key, message)])
  end

  defp get_options!(conn) do
    defaults = %{
      module: Oidcc,
      redirect_uri: callback_url(conn),
      response_type: "code",
      scopes: ["openid"]
    }

    compile_opts = Map.new(options(conn))

    runtime_opts =
      Map.new(
        (Application.get_env(:ueberauth_oidcc, :strategies) || [])[strategy_name(conn)] || %{}
      )

    defaults
    |> Map.merge(compile_opts)
    |> Map.merge(runtime_opts)
  end

  defp add_optional_url(urls, field, value)
  defp add_optional_url(urls, _field, nil), do: urls
  defp add_optional_url(urls, field, value), do: Map.put(urls, field, value)

  defp to_url_extension(enum) do
    for {key, value} <- enum do
      key =
        case key do
          a when is_atom(a) -> Atom.to_string(a)
          b when is_binary(b) -> b
        end

      {key, value}
    end
  end
end