• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MarkUsProject / Markus / 20143075828

11 Dec 2025 06:18PM UTC coverage: 91.513%. Remained the same
20143075828

Pull #7763

github

web-flow
Merge 9f55e660a into 3421ef3b2
Pull Request #7763: Release 2.9.0

914 of 1805 branches covered (50.64%)

Branch coverage included in aggregate %.

1584 of 1666 new or added lines in 108 files covered. (95.08%)

573 existing lines in 35 files now uncovered.

43650 of 46892 relevant lines covered (93.09%)

121.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.73
/app/controllers/lti_deployments_controller.rb
1
class LtiDeploymentsController < ApplicationController
1✔
2
  skip_verify_authorized except: [:choose_course]
1✔
3
  skip_forgery_protection except: [:choose_course]
1✔
4

5
  before_action :authenticate, :check_course_switch, :check_record,
1✔
6
                except: [:get_config, :launch, :public_jwk, :redirect_login]
7
  before_action(except: [:get_config, :launch, :public_jwk, :redirect_login]) { authorize! }
20✔
8
  before_action :check_host, only: [:launch, :redirect_login]
1✔
9

10
  USE_SECURE_COOKIES = !Rails.env.local?
1✔
11

12
  def launch
1✔
13
    if params[:client_id].blank? || params[:login_hint].blank? ||
7✔
14
      params[:target_link_uri].blank? || params[:lti_message_hint].blank?
15
      head :unprocessable_content
5✔
16
      return
5✔
17
    end
18
    nonce = rand(10 ** 30).to_s.rjust(30, '0')
2✔
19
    session_nonce = rand(10 ** 30).to_s.rjust(30, '0')
2✔
20
    lti_launch_data = {}
2✔
21
    lti_launch_data[:client_id] = params[:client_id]
2✔
22
    lti_launch_data[:iss] = params[:iss]
2✔
23
    lti_launch_data[:nonce] = nonce
2✔
24
    lti_launch_data[:state] = session_nonce
2✔
25
    cookies.permanent.encrypted[:lti_launch_data] =
2✔
26
      { value: JSON.generate(lti_launch_data), expires: 1.hour.from_now, same_site: :none, secure: USE_SECURE_COOKIES }
27
    auth_params = {
28
      scope: 'openid',
2✔
29
      response_type: 'id_token',
30
      client_id: params[:client_id],
31
      redirect_uri: params[:target_link_uri],
32
      lti_message_hint: params[:lti_message_hint],
33
      login_hint: params[:login_hint],
34
      response_mode: 'form_post',
35
      nonce: nonce,
36
      prompt: 'none',
37
      state: session_nonce
38
    }
39
    auth_request_uri = construct_redirect_with_port(request.referer, endpoint: self.class::LMS_REDIRECT_ENDPOINT)
2✔
40

41
    http = Net::HTTP.new(auth_request_uri.host, auth_request_uri.port)
2✔
42
    http.use_ssl = true if auth_request_uri.instance_of? URI::HTTPS
2✔
43
    req = Net::HTTP::Post.new(auth_request_uri)
2✔
44
    req.set_form_data(auth_params)
2✔
45

46
    res = http.request(req)
2✔
47
    location = URI(res['location'])
2✔
48
    location.query = auth_params.to_query
2✔
49
    redirect_to location.to_s, allow_other_host: true
2✔
50
  end
51

52
  def redirect_login
1✔
53
    if request.post?
23✔
54
      lti_launch_data = JSON.parse(cookies.encrypted[:lti_launch_data]).symbolize_keys
13✔
55

56
      if params[:id_token].blank? || params[:state] != lti_launch_data[:state]
13✔
57
        render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') },
3✔
58
                                     layout: false
59
        return
3✔
60
      end
61
      # Get LMS JWK set
62
      jwk_url = construct_redirect_with_port(request.referer, endpoint: self.class::LMS_JWK_ENDPOINT)
10✔
63
      # A list of public keys and associated metadata for JWTs signed by the LMS
64
      lms_jwks = JSON.parse(Net::HTTP.get_response(jwk_url).body)
10✔
65
      begin
66
        decoded_token = JWT.decode(
10✔
67
          params[:id_token], # Encoded JWT signed by LMS
68
          nil, # If the token is passphrase-protected, set the passphrase here
69
          true, # Verify the signature of this token
70
          algorithms: ['RS256'],
71
          iss: lti_launch_data[:iss],
72
          verify_iss: true,
73
          aud: lti_launch_data[:client_id], # OpenID Connect uses client ID as the aud parameter
74
          verify_aud: true,
75
          jwks: lms_jwks # The correct JWK will be selected by matching jwk kid param with id_token kid
76
        )
77
        lti_params = decoded_token[0]
10✔
78
        unless lti_params['nonce'] == lti_launch_data[:nonce]
10✔
79
          render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') },
×
80
                                       layout: false
81
          return
×
82
        end
83
      rescue JWT::DecodeError
84
        render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') },
×
85
                                     layout: false
86
        return
×
87
      end
88

89
      lti_data = { host: construct_redirect_with_port(request.referer).to_s,
10✔
90
                   client_id: lti_launch_data[:client_id],
91
                   deployment_id: lti_params[LtiDeployment::LTI_CLAIMS[:deployment_id]],
92
                   lms_course_name: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['title'],
93
                   lms_course_label: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['label'],
94
                   lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'],
95
                   user_roles: lti_params[LtiDeployment::LTI_CLAIMS[:roles]] }
96
      if lti_params.key?(LtiDeployment::LTI_CLAIMS[:rlid])
10✔
NEW
97
        rlid = lti_params[LtiDeployment::LTI_CLAIMS[:rlid]]['id']
×
NEW
98
        lti_data[:resource_link_id] = rlid
×
99
      end
100
      if lti_params.key?(LtiDeployment::LTI_CLAIMS[:names_role])
10✔
UNCOV
101
        name_and_roles_endpoint = lti_params[LtiDeployment::LTI_CLAIMS[:names_role]]['context_memberships_url']
×
102
        lti_data[:names_role_service] = name_and_roles_endpoint
×
103
      end
104
      if lti_params.key?(LtiDeployment::LTI_CLAIMS[:ags_lineitem])
10✔
UNCOV
105
        grades_endpoints = lti_params[LtiDeployment::LTI_CLAIMS[:ags_lineitem]]
×
106
        if grades_endpoints.key?('lineitems')
×
107
          lti_data[:line_items] = grades_endpoints['lineitems']
×
108
        end
109
      end
110
      lti_data[:lti_user_id] = lti_params[LtiDeployment::LTI_CLAIMS[:user_id]]
10✔
111
      unless logged_in?
10✔
112
        lti_data[:lti_redirect] = request.url
×
UNCOV
113
        cookies.encrypted.permanent[:lti_data] =
×
114
          { value: JSON.generate(lti_data), expires: 1.hour.from_now, same_site: :none, secure: USE_SECURE_COOKIES }
UNCOV
115
        redirect_to root_path
×
UNCOV
116
        return
×
117
      end
118
    elsif logged_in? && cookies.encrypted[:lti_data].present?
10✔
119
      lti_data = JSON.parse(cookies.encrypted[:lti_data]).symbolize_keys
7✔
120
      cookies.delete(:lti_data)
7✔
121
    else
122
      render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') },
3✔
123
                                   layout: false
124
      return
3✔
125
    end
126
    lti_client = LtiClient.find_or_create_by(client_id: lti_data[:client_id], host: lti_data[:host])
17✔
127
    lti_deployment = LtiDeployment.find_or_initialize_by(lti_client: lti_client,
17✔
128
                                                         external_deployment_id: lti_data[:deployment_id],
129
                                                         lms_course_id: lti_data[:lms_course_id])
130
    lti_deployment.update!(
17✔
131
      lms_course_name: lti_data[:lms_course_name],
132
      resource_link_id: lti_data[:resource_link_id]
133
    )
134
    session[:lti_course_label] = lti_data[:lms_course_label]
17✔
135
    if lti_data.key?(:names_role_service)
17✔
UNCOV
136
      names_service = LtiService.find_or_initialize_by(lti_deployment: lti_deployment, service_type: 'namesrole')
×
UNCOV
137
      names_service.update!(url: lti_data[:names_role_service])
×
138
    end
139
    if lti_data.key?(:line_items)
17✔
UNCOV
140
      lineitem_service = LtiService.find_or_initialize_by(lti_deployment: lti_deployment, service_type: 'agslineitem')
×
UNCOV
141
      lineitem_service.update!(url: lti_data[:line_items])
×
142
    end
143
    LtiUser.find_or_create_by(user: @real_user, lti_client: lti_client,
17✔
144
                              lti_user_id: lti_data[:lti_user_id])
145
    if lti_deployment.course.nil?
17✔
146
      # Check if the user has any of the privileged roles
147
      has_privileged_role = lti_data[:user_roles].any? do |role_uri|
17✔
148
        LtiDeployment::LTI_PRIVILEGED_ROLES.include?(role_uri)
18✔
149
      end
150
      has_ta_role = lti_data[:user_roles].include?(LtiDeployment::LTI_ROLES[:ta])
17✔
151
      if has_privileged_role && !has_ta_role
17✔
152
        redirect_to choose_course_lti_deployment_path(lti_deployment)
15✔
153
      else
154
        redirect_to course_not_set_up_lti_deployment_path(lti_deployment)
2✔
155
      end
156
    else
157
      # Course is linked, proceed to the course path
UNCOV
158
      redirect_to course_path(lti_deployment.course)
×
159
    end
160
  ensure
161
    cookies.delete(:lti_launch_data)
23✔
162
  end
163

164
  def public_jwk
1✔
165
    key = OpenSSL::PKey::RSA.new File.read(LtiClient::KEY_PATH)
8✔
166
    jwk = JWT::JWK.new(key)
8✔
167
    render json: { keys: [jwk.export] }
8✔
168
  end
169

170
  def course_not_set_up
1✔
NEW
171
    render 'course_not_set_up', status: :not_found
×
172
  end
173

174
  def choose_course
1✔
175
    @lti_deployment = record
7✔
176
    if request.post?
7✔
177
      begin
178
        course = Course.find(params[:course])
6✔
179
        unless allowed_to?(:manage_lti_deployments?, course, with: CoursePolicy)
6✔
180
          flash_message(:error, t('lti.course_link_error'))
2✔
181
          render 'choose_course'
2✔
182
          return
2✔
183
        end
184
        @lti_deployment.update!(course: course)
4✔
185
      rescue StandardError
UNCOV
186
        flash_message(:error, t('lti.course_link_error'))
×
UNCOV
187
        render 'choose_course'
×
188
      else
189
        flash_message(:success, t('lti.course_link_success', markus_course_name: course.name))
4✔
190
        redirect_to course_path(course)
4✔
191
      end
192
    end
193
  end
194

195
  def check_host
1✔
196
    known_lti_hosts = Settings.lti.domains
31✔
197
    known_lti_hosts << URI(root_url).host
31✔
198
    if known_lti_hosts.exclude?(URI(request.referer).host)
31✔
199
      render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') },
1✔
200
                                   status: :unprocessable_content, layout: false
201
      nil
202
    end
203
  end
204

205
  def create_course
1✔
206
    if LtiConfig.respond_to?(:allowed_to_create_course?) && !LtiConfig.allowed_to_create_course?(record)
12✔
207
      @title = I18n.t('lti.course_creation_denied')
2✔
208
      @message = format(
2✔
209
        Settings.lti.unpermitted_new_course_message,
210
        course_name: record.lms_course_name
211
      )
212
      render 'message', status: :forbidden
2✔
213
      return
2✔
214
    end
215

216
    name = params['name'].gsub(/[^a-zA-Z0-9\-_]/, '-')  # Sanitize name to comply with Course name validation
10✔
217
    new_course = Course.find_or_initialize_by(name: name)
10✔
218
    unless new_course.new_record?
10✔
219
      flash_message(:error, I18n.t('lti.course_exists'))
2✔
220
      redirect_to choose_course_lti_deployment_path
2✔
221
      return
2✔
222
    end
223
    new_course.update!(display_name: params['display_name'], is_hidden: true)
8✔
224
    if current_user.admin_user?
8✔
225
      AdminRole.find_or_create_by(user: current_user, course: new_course)
3✔
226
    else
227
      Instructor.find_or_create_by(user: current_user, course: new_course)
5✔
228
    end
229
    lti_deployment = record
8✔
230
    lti_deployment.update!(course: new_course)
8✔
231
    redirect_to edit_course_path(new_course)
8✔
232
  end
233

234
  def get_config
1✔
UNCOV
235
    raise NotImplementedError
×
236
  end
237

238
  # Takes a string and returns a URI corresponding to the redirect
239
  # endpoint for the lms
240
  def construct_redirect_with_port(url, endpoint: nil)
1✔
241
    referer = URI(url)
22✔
242
    referer_host = "#{referer.scheme}://#{referer.host}"
22✔
243
    referer_host_with_port = "#{referer_host}:#{referer.port}"
22✔
244
    referer_host = referer_host_with_port if referer.to_s.start_with?(referer_host_with_port)
22✔
245
    URI("#{referer_host}#{endpoint}")
22✔
246
  end
247

248
  # Define default URL options to not include locale
249
  def default_url_options(_options = {})
1✔
250
    {}
159✔
251
  end
252
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc