lib/lti_1p3/tool/launch_validation.ex

defmodule Lti_1p3.Tool.LaunchValidation do
  import Lti_1p3.Config
  import Lti_1p3.Utils

  @message_validators [
    Lti_1p3.Tool.MessageValidators.ResourceMessageValidator
  ]

  @type params() :: %{state: binary(), id_token: binary()}
  @type validate_opts() :: []

  @doc """
  Validates an incoming LTI 1.3 launch and returns the claims if successful.
  """
  @spec validate(params(), validate_opts()) ::
          {:ok, any()} | {:error, %{optional(atom()) => any(), reason: atom(), msg: String.t()}}
  def validate(params, session_state, _opts \\ []) do
    with {:ok} <- validate_oidc_state(params, session_state),
         {:ok, registration} <- validate_registration(params),
         {:ok, key_set_url} <- registration_key_set_url(registration),
         {:ok, id_token} <- extract_param(params, "id_token"),
         {:ok, jwt_body} <- validate_jwt_signature(id_token, key_set_url),
         {:ok} <- validate_timestamps(jwt_body),
         {:ok} <- validate_deployment(registration, jwt_body),
         {:ok} <- validate_message(jwt_body),
         {:ok} <- validate_nonce(jwt_body, "validate_launch"),
         claims <- jwt_body do
      {:ok, claims}
    end
  end

  # Validate that the state sent with an OIDC launch matches the state that was sent in the OIDC response
  # returns a boolean on whether it is valid or not
  defp validate_oidc_state(params, session_state) do
    case session_state do
      nil ->
        {:error,
         %{
           reason: :invalid_oidc_state,
           msg:
             "State from session is missing. Make sure cookies are enabled and configured correctly"
         }}

      session_state ->
        case params["state"] do
          nil ->
            {:error, %{reason: :invalid_oidc_state, msg: "State from OIDC request is missing"}}

          request_state ->
            if request_state == session_state do
              {:ok}
            else
              {:error,
               %{
                 reason: :invalid_oidc_state,
                 msg: "State from OIDC request does not match session"
               }}
            end
        end
    end
  end

  defp validate_registration(params) do
    with {:ok, issuer, client_id} <- peek_issuer_client_id(params) do
      case provider!().get_registration_by_issuer_client_id(issuer, client_id) do
        nil ->
          {:error,
           %{
             reason: :invalid_registration,
             msg:
               "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found",
             issuer: issuer,
             client_id: client_id
           }}

        registration ->
          {:ok, registration}
      end
    end
  end

  defp peek_issuer_client_id(params) do
    with {:ok, jwt_string} <- extract_param(params, "id_token"),
         {:ok, jwt_claims} <- peek_claims(jwt_string) do
      {:ok, jwt_claims["iss"], peek_client_id(jwt_claims["aud"])}
    end
  end

  defp peek_client_id([client_id | _]), do: client_id
  defp peek_client_id(client_id), do: client_id

  defp validate_deployment(registration, jwt_body) do
    deployment_id = jwt_body["https://purl.imsglobal.org/spec/lti/claim/deployment_id"]
    deployment = provider!().get_deployment(registration, deployment_id)

    case deployment do
      nil ->
        {:error,
         %{
           reason: :invalid_deployment,
           msg: "Deployment with id \"#{deployment_id}\" not found",
           registration_id: registration.id,
           deployment_id: deployment_id
         }}

      _deployment ->
        {:ok}
    end
  end

  defp validate_message(jwt_body) do
    case jwt_body["https://purl.imsglobal.org/spec/lti/claim/message_type"] do
      nil ->
        {:error, %{reason: :invalid_message_type, msg: "Missing message type"}}

      message_type ->
        # no more than one message validator should apply for a given mesage,
        # so use the first validator we find that applies
        validation_result =
          case Enum.find(@message_validators, fn mv -> mv.can_validate(jwt_body) end) do
            nil -> nil
            validator -> validator.validate(jwt_body)
          end

        case validation_result do
          nil ->
            {:error,
             %{
               reason: :invalid_message_type,
               msg: "Invalid or unsupported message type \"#{message_type}\""
             }}

          {:error, error} ->
            {:error,
             %{
               reason: :invalid_message,
               msg: "Message validation failed: (\"#{message_type}\") #{error}"
             }}

          _ ->
            {:ok}
        end
    end
  end
end