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

MarkUsProject / Markus / 18991026219

01 Nov 2025 03:57AM UTC coverage: 91.572% (+0.002%) from 91.57%
18991026219

Pull #7714

github

web-flow
Merge 0c8764b65 into 1ea4bb8fb
Pull Request #7714: Ensure only instructors and admins can link course, as LMS launch MarkUs button made available for all users

794 of 1648 branches covered (48.18%)

Branch coverage included in aggregate %.

49 of 52 new or added lines in 4 files covered. (94.23%)

1 existing line in 1 file now uncovered.

42795 of 45953 relevant lines covered (93.13%)

121.18 hits per line

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

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

156
  def public_jwk
1✔
157
    key = OpenSSL::PKey::RSA.new File.read(LtiClient::KEY_PATH)
8✔
158
    jwk = JWT::JWK.new(key)
8✔
159
    render json: { keys: [jwk.export] }
8✔
160
  end
161

162
  def course_not_set_up
1✔
NEW
163
    @title = I18n.t('lti.course_not_found')
×
NEW
164
    @message = I18n.t('lti.course_not_set_up')
×
NEW
165
    render 'course_not_set_up', status: :not_found
×
166
  end
167

168
  def choose_course
1✔
169
    @lti_deployment = record
6✔
170
    if request.post?
6✔
171
      begin
172
        course = Course.find(params[:course])
5✔
173
        unless allowed_to?(:manage_lti_deployments?, course, with: CoursePolicy)
5✔
174
          flash_message(:error, t('lti.course_link_error'))
2✔
175
          render 'choose_course'
2✔
176
          return
2✔
177
        end
178
        @lti_deployment.update!(course: course)
3✔
179
      rescue StandardError
180
        flash_message(:error, t('lti.course_link_error'))
×
181
        render 'choose_course'
×
182
      else
183
        flash_message(:success, t('lti.course_link_success', markus_course_name: course.name))
3✔
184
        redirect_to course_path(course)
3✔
185
      end
186
    end
187
  end
188

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

199
  def create_course
1✔
200
    if LtiConfig.respond_to?(:allowed_to_create_course?) && !LtiConfig.allowed_to_create_course?(record)
11✔
201
      @title = I18n.t('lti.course_creation_denied')
2✔
202
      @message = format(
2✔
203
        Settings.lti.unpermitted_new_course_message,
204
        course_name: record.lms_course_name
205
      )
206
      render 'message', status: :forbidden
2✔
207
      return
2✔
208
    end
209

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

228
  def get_config
1✔
229
    raise NotImplementedError
×
230
  end
231

232
  # Takes a string and returns a URI corresponding to the redirect
233
  # endpoint for the lms
234
  def construct_redirect_with_port(url, endpoint: nil)
1✔
235
    referer = URI(url)
22✔
236
    referer_host = "#{referer.scheme}://#{referer.host}"
22✔
237
    referer_host_with_port = "#{referer_host}:#{referer.port}"
22✔
238
    referer_host = referer_host_with_port if referer.to_s.start_with?(referer_host_with_port)
22✔
239
    URI("#{referer_host}#{endpoint}")
22✔
240
  end
241

242
  # Define default URL options to not include locale
243
  def default_url_options(_options = {})
1✔
244
    {}
155✔
245
  end
246
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

© 2025 Coveralls, Inc