Files
Claper/lib/lti_13/tool/launch_validation.ex
2024-12-28 17:54:13 -05:00

209 lines
6.7 KiB
Elixir

defmodule Lti13.Tool.LaunchValidation do
import Lti13.Jwks.Validator
alias Lti13.Deployments
alias Lti13.Registrations
@message_validators [
Lti13.Tool.MessageValidators.ResourceMessageValidator
]
@authorized_to_create_event_roles [
"http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
]
@doc """
Validates an incoming LTI 1.3 launch and returns the claims if successful.
"""
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, lti_user} <- validate_user(jwt_body, registration),
{:ok} <- validate_nonce(lti_user, jwt_body, "validate_launch"),
{:ok, is_instructor} <- validate_role(jwt_body),
{:ok, resource} <- validate_resource(jwt_body, lti_user, registration, is_instructor),
claims <- jwt_body do
{:ok, %{claims: claims, lti_user: lti_user, resource: resource}}
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"
}}
_ ->
compare_oidc_states(params["state"], session_state)
end
end
defp compare_oidc_states(nil, _),
do: {:error, %{reason: :invalid_oidc_state, msg: "State from OIDC request is missing"}}
defp compare_oidc_states(request_state, session_state) when request_state == session_state,
do: {:ok}
defp compare_oidc_states(_, _),
do:
{:error,
%{reason: :invalid_oidc_state, msg: "State from OIDC request does not match session"}}
defp validate_registration(params) do
with {:ok, issuer, client_id} <- peek_issuer_client_id(params) do
case Registrations.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 validate_resource(
%{
"https://purl.imsglobal.org/spec/lti/claim/custom" => %{
"resource_title" => title,
"resource_id" => resource_id
},
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" => %{
"lineitems" => line_items_url
}
},
lti_user,
registration,
is_instructor
) do
case Lti13.Resources.get_resource_by_id_and_registration(resource_id, registration.id) do
nil -> handle_missing_resource(title, resource_id, lti_user, line_items_url, is_instructor)
resource -> handle_existing_resource(resource, lti_user, is_instructor)
end
end
defp handle_missing_resource(title, resource_id, lti_user, line_items_url, true) do
case Lti13.Resources.create_resource_with_event(%{
title: title,
resource_id: resource_id,
line_items_url: line_items_url,
lti_user: lti_user
}) do
{:ok, resource} -> {:ok, resource}
{:error, _} -> {:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
end
end
defp handle_existing_resource(resource, lti_user, true) do
maybe_create_activity_leader(resource, lti_user)
{:ok, resource}
end
defp handle_existing_resource(resource, _, false), do: {:ok, resource}
defp maybe_create_activity_leader(resource, lti_user) do
activity_leaders = Claper.Events.get_activity_leaders_for_event(resource.event_id)
activity_leaders_emails = Enum.map(activity_leaders, fn al -> al.email end)
if lti_user.email not in activity_leaders_emails && resource.event.user_id != lti_user.user_id do
Claper.Events.create_activity_leader(%{
email: lti_user.email,
user_id: lti_user.id,
event_id: resource.event_id
})
end
end
defp validate_role(jwt) do
roles = jwt["https://purl.imsglobal.org/spec/lti/claim/roles"]
is_instructor = Enum.any?(roles, fn role -> role in @authorized_to_create_event_roles end)
{:ok, is_instructor}
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 = Deployments.get_deployment(registration.id, 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 ->
validate_message_type(jwt_body, message_type)
end
end
defp validate_message_type(jwt_body, message_type) do
case apply_message_validator(jwt_body) 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
defp apply_message_validator(jwt_body) do
case Enum.find(@message_validators, fn mv -> mv.can_validate(jwt_body) end) do
nil -> nil
validator -> validator.validate(jwt_body)
end
end
end