lib/oidcc/plug/introspect_token.ex

defmodule Oidcc.Plug.IntrospectToken do
  @moduledoc """
  Validate extracted authorization token using introspection.

  See: https://datatracker.ietf.org/doc/html/rfc7662

  This module should be used together with `Oidcc.Plug.ExtractAuthorization`.

  This plug will send an introspection request for every request. To avoid this,
  provide a `cache` to `t:opts/0`.

  ```elixir
  defmodule SampleAppWeb.Endpoint do
    use Phoenix.Endpoint, otp_app: :sample_app

    # ...

    plug Oidcc.Plug.ExtractAuthorization

    plug Oidcc.Plug.IntrospectToken,
      provider: SampleApp.GoogleOpenIdConfigurationProvider,
      client_id: Application.compile_env!(:sample_app, [Oidcc.Plug.IntrospectToken, :client_id]),
      client_secret: Application.compile_env!(:sample_app, [Oidcc.Plug.IntrospectToken, :client_secret])

    plug SampleAppWeb.Router
  end
  ```
  """
  @moduledoc since: "0.1.0"

  @behaviour Plug

  import Plug.Conn, only: [put_private: 3, halt: 1, send_resp: 3]

  alias Oidcc.Plug.ExtractAuthorization
  alias Oidcc.Plug.Utils
  alias Oidcc.TokenIntrospection

  @typedoc """
  Plug Configuration Options

  ## Options

  * `provider` - name of the `Oidcc.ProviderConfiguration.Worker`
  * `client_id` - OAuth Client ID to use for the introspection
  * `client_secret` - OAuth Client Secret to use for the introspection
  * `token_introspection_opts` - Options to pass to the introspection
  * `send_inactive_token_response` - Customize Error Response for inactive token
  * `cache` - Cache token introspection - See `Oidcc.Plug.Cache`
  * `client_store` - A module name that implements the `Oidcc.Plug.ClientStore` behaviour
  to fetch the client context from a store instead of using the `provider`, `client_id` and `client_secret`
  directly. This is useful for storing the client context in a database or other persistent
  storage.
  """
  @typedoc since: "0.1.0"
  @type opts :: [
          provider: GenServer.name(),
          client_id: String.t() | (-> String.t()),
          client_secret: String.t() | (-> String.t()),
          token_introspection_opts: :oidcc_token_introspection.opts(),
          send_inactive_token_response: (conn :: Plug.Conn.t(), introspection :: TokenIntrospection.t() ->
                                           Plug.Conn.t()),
          cache: Oidcc.Plug.Cache.t()
        ]

  defmodule Error do
    @moduledoc """
    Introspection Failed

    Check the `reason` field for ther exact reason
    """
    @moduledoc since: "0.1.0"

    defexception [:reason]

    @impl Exception
    def message(_exception), do: "Introspection Failed"
  end

  @impl Plug
  def init(opts),
    do:
      opts
      |> Keyword.validate!([
        :provider,
        :client_id,
        :client_secret,
        :client_store,
        token_introspection_opts: %{},
        send_inactive_token_response: &__MODULE__.send_inactive_token_response/2,
        cache: Oidcc.Plug.Cache.Noop
      ])
      |> Utils.validate_client_context_opts!()

  @impl Plug
  def call(%Plug.Conn{private: %{ExtractAuthorization => nil}} = conn, _opts), do: conn

  def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts) do
    token_introspection_opts = Keyword.fetch!(opts, :token_introspection_opts)

    send_inactive_token_response = Keyword.fetch!(opts, :send_inactive_token_response)

    cache = Keyword.fetch!(opts, :cache)

    case cache.get(:introspection, access_token, conn) do
      {:ok, %TokenIntrospection{active: true} = introspection} ->
        put_private(conn, __MODULE__, introspection)

      {:ok, %TokenIntrospection{active: false} = introspection} ->
        conn
        |> put_private(__MODULE__, introspection)
        |> send_inactive_token_response.(introspection)

      :miss ->
        with {:ok, client_context} <- Utils.get_client_context(conn, opts),
             {:ok, %TokenIntrospection{} = token_introspection} <-
               TokenIntrospection.introspect(
                 access_token,
                 client_context,
                 token_introspection_opts
               ) do
          :ok = cache.put(:introspection, access_token, token_introspection, conn)

          if token_introspection.active do
            put_private(conn, __MODULE__, token_introspection)
          else
            conn
            |> put_private(__MODULE__, token_introspection)
            |> send_inactive_token_response.(token_introspection)
          end
        else
          {:error, reason} ->
            raise Error, reason: reason
        end
    end
  end

  def call(%Plug.Conn{} = _conn, _opts) do
    raise """
    The plug Oidcc.Plug.ExtractAuthorization must be run before this plug
    """
  end

  @doc false
  @spec send_inactive_token_response(
          conn :: Plug.Conn.t(),
          introspection :: TokenIntrospection.t()
        ) :: Plug.Conn.t()
  def send_inactive_token_response(conn, _introspection) do
    conn
    |> halt()
    |> send_resp(:unauthorized, "The provided token is inactive")
  end
end