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

  @session_key "ueberauth_strategy_oidcc"

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

    # Nonce: stored as raw bytes, sent as an encoded SHA512 string. This is the
    # approach recommended by the spec:
    # https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes
    # 64 random bytes is the same size as the SHA512 output.
    raw_nonce = :crypto.strong_rand_bytes(64)

    # PKCE Verifier: a 43 - 128 character string from the alphabet [A-Z] / [a-z]
    # / [0-9] / "-" / "." / "_" / "~". The recommendation is to generate a
    # random sequence and then base64url-encode it.
    # https://datatracker.ietf.org/doc/html/rfc7636#page-8
    # 96 random bytes results in an encoded 128 byte verifier.
    pkce_verifier = url_encode64(:crypto.strong_rand_bytes(96))

    redirect_params =
      [
        response_type: opts.response_type,
        redirect_uri: opts.redirect_uri,
        pkce_verifier: pkce_verifier,
        nonce: url_encode64(:crypto.hash(:sha512, raw_nonce)),
        scopes: opts.scopes
      ]
      |> with_state_param(conn)
      |> Map.new()

    case create_redirect_url(opts, redirect_params) do
      {:ok, uri} ->
        conn
        |> put_session(@session_key, %{
          raw_nonce: raw_nonce,
          pkce_verifier: pkce_verifier,
          redirect_uri: opts.redirect_uri
        })
        |> redirect!(IO.iodata_to_binary(uri))

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

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

  @doc """
  Handles the callback from the oidc provider.
  """
  def handle_callback!(%{params: %{"code" => code}} = conn) when is_binary(code) do
    session = get_session(conn, @session_key, %{})

    opts = get_options!(conn)

    conn =
      conn
      |> delete_session(@session_key)
      |> put_private(:ueberauth_oidcc_opts, opts)

    userinfo? = Map.get(opts, :userinfo, false)

    nonce =
      case Map.fetch(session, :raw_nonce) do
        {:ok, raw_nonce} -> url_encode64(:crypto.hash(:sha512, raw_nonce))
        :error -> :any
      end

    retrieve_token_params =
      Map.merge(
        opts,
        %{
          nonce: nonce,
          pkce_verifier: Map.get(session, :pkce_verifier, :none)
        }
      )

    maybe_token =
      with :ok <- validate_redirect_uri(Map.get(session, :redirect_uri, :any), conn),
           {:ok, token} <-
             opts.module.retrieve_token(
               code,
               opts.issuer,
               opts.client_id,
               opts.client_secret,
               retrieve_token_params
             ) do
        {:ok, token}
      else
        {:error, {:none_alg_used, token}} when userinfo? ->
          # the none algorithm is okay for the ID token if we then verify the
          # userinfo (oidcc-client-test-idtoken-sig-none)
          {:ok, token}

        {:error, reason} ->
          {:error, reason}
      end

    with {:ok, token} <- maybe_token,
         :ok <- validate_token_scopes(token, retrieve_token_params.scopes, opts.validate_scopes) do
      conn
      |> put_private(:ueberauth_oidcc_token, token)
      |> maybe_put_userinfo(userinfo?)
    else
      {:error, {:additional_scopes, scopes}} ->
        set_error!(
          conn,
          "retrieve_token",
          "Unrequested scopes received: #{Enum.intersperse(scopes, " ")}"
        )

      {:error, {:invalid_redirect_uri, uri}} ->
        set_error!(conn, "retrieve_token", "Redirected to the wrong URI: #{uri}")

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

  def handle_callback!(conn) do
    set_error!(conn, "code", "Query string does not contain field 'code'")
  end

  defp create_redirect_url(opts, redirect_params)

  defp create_redirect_url(
         %{authorization_endpoint: authorization_endpoint} = opts,
         redirect_params
       )
       when is_binary(authorization_endpoint) do
    redirect_params =
      redirect_params
      |> Map.put(:client_id, opts.client_id)
      |> Map.put(:scope, Enum.join(redirect_params.scopes, " "))
      |> Map.delete(:scopes)
      |> Map.merge(Map.get(opts, :authorization_params, %{}))

    query = URI.encode_query(redirect_params)
    {:ok, "#{authorization_endpoint}?#{query}"}
  end

  defp create_redirect_url(opts, redirect_params) do
    redirect_params =
      case Map.fetch(opts, :authorization_params) do
        {:ok, additional} ->
          Map.put(redirect_params, :url_extension, to_url_extension(additional))

        :error ->
          redirect_params
      end

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

      %{client_id: _} ->
        {:error, "Missing issuer"}

      %{} ->
        {:error, "Missing client_id"}
    end
  end

  # https://openid.net/specs/openid-financial-api-part-1-1_0.html#public-client
  # > shall store the redirect URI value in the resource owner's user-agents
  # > (such as browser) session and compare it with the redirect URI that the
  # > authorization response was received at, where, if the URIs do not match, the
  # > client shall terminate the process with error
  defp validate_redirect_uri(:any, _) do
    :ok
  end

  defp validate_redirect_uri(uri, conn) do
    # generate the current URL but without the query string parameters
    case Plug.Conn.request_url(%{conn | query_string: ""}) do
      ^uri ->
        :ok

      actual_uri ->
        {:error, {:invalid_redirect_uri, actual_uri}}
    end
  end

  defp validate_token_scopes(token, requested_scopes, validate_scopes?)

  defp validate_token_scopes(_, _, false) do
    :ok
  end

  defp validate_token_scopes(token, requested_scopes, true) do
    # https://openid.net/specs/openid-financial-api-part-1-1_0.html#public-client
    # # > shall verify that the scope received in the token response is either
    # > an exact match, or contains a subset of the scope sent in the
    # > authorization request; and
    additional_scopes =
      for scope <- token.scope,
          scope not in requested_scopes do
        scope
      end

    if additional_scopes == [] do
      :ok
    else
      {:error, {:additional_scopes, additional_scopes}}
    end
  end

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

    case opts.module.retrieve_userinfo(
           conn.private.ueberauth_oidcc_token,
           opts.issuer,
           opts.client_id,
           opts.client_secret,
           opts
         ) do
      {:ok, userinfo} ->
        put_private(conn, :ueberauth_oidcc_userinfo, userinfo)

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

  defp maybe_put_userinfo(conn, false) do
    conn
  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: %UeberauthOidcc.RawInfo{
        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) when is_binary(key) and is_binary(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"],
      validate_scopes: false
    }

    compile_opts = Map.new(options(conn))

    runtime_opts =
      Map.new(
        (Application.get_env(:ueberauth_oidcc, :providers) || [])[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

  defp url_encode64(bytes) do
    Base.url_encode64(bytes, padding: false)
  end
end