panda_pal 5.17.2 → 5.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d2cfdbfc9d2c60cc080b545d2ee582ce212623a45c286d8610b8ac5f6c1af37
4
- data.tar.gz: 4918d3bee6808bfbf22e98e23f3cbedcd493d4ec7c946ac67e0c8ca5485d9eca
3
+ metadata.gz: a84b75147fd0b4ac07c4f0aafdda286b9e1f7a5cb16d64b8139e94b12ff82707
4
+ data.tar.gz: ff14468ea46d91e4aca58048366e9bd93a481ec985ccb87d29ca270d70bd2f18
5
5
  SHA512:
6
- metadata.gz: 0e06e419047c6a29e1ed8f03966bae4c31ed9e5b57ad6953133b890f6dab407d384ea1f071e0c88e01f7a69ef790f9b132f98a973aa3602b6b902d4871722799
7
- data.tar.gz: 6d19e0a9ea13914f8045d62525dd73dcb97aadfc780dc1c4907fe3b80df9483144d1cc3cc087d509aa7cfba344aa64aaa4b7bd36b05fea354201d7e387094db4
6
+ metadata.gz: 8c885e4859fd35e5ec7f902ccf3edbe097485485e2907a7ecad04a4369d22cd0c5b7d22316dfa9d9e9d205b349ff26a06462baa87c13563a0599e6b6cdfa135c
7
+ data.tar.gz: 8d91973e5c67bc16fdca3a4cb0b9493afc35d5d823f221caee125e1db1042915bd68f21a30d78e65bd81b472f315203fb64ae22a601be5109aad2ddf1b6e162f
data/AGS.md ADDED
@@ -0,0 +1,197 @@
1
+ # Assignment and Grade Services (LTI 1.3)
2
+
3
+ This document explains how to post scores and read line items and results through LTI Advantage
4
+ Assignment and Grade Services (AGS) in your PandaPal application. AGS replaces the LTI 1.1 Basic
5
+ Outcomes passback (`PandaPal::Jobs::GradePassbackJob`) for LTI 1.3 launches.
6
+
7
+ ## Configuration
8
+
9
+ ### 1. Request the AGS scopes in the tool configuration
10
+
11
+ The scopes you declare are published as `scopes` in the generated `/v1p3/config` JSON, which
12
+ Canvas copies onto the Developer Key when the tool is installed or updated.
13
+
14
+ ```ruby
15
+ # config/initializers/lti.rb
16
+ PandaPal.lti_scopes = [
17
+ PandaPal::LtiConstants::AgsScopes::SCORE,
18
+ PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
19
+ PandaPal::LtiConstants::AgsScopes::RESULT_READONLY,
20
+ ]
21
+ ```
22
+
23
+ `PandaPal.lti_scopes` defaults to `[]`, so tools that do not set it publish exactly the same
24
+ configuration as before.
25
+
26
+ Available scope constants (`PandaPal::LtiConstants::AgsScopes`):
27
+
28
+ | Constant | Scope | Needed for |
29
+ |----------|-------|------------|
30
+ | `SCORE` | `https://purl.imsglobal.org/spec/lti-ags/scope/score` | `post_score` |
31
+ | `LINEITEM_READONLY` | `https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly` | `line_items`, `line_item` |
32
+ | `LINEITEM` | `https://purl.imsglobal.org/spec/lti-ags/scope/lineitem` | `line_items`, `line_item` (full line item management) |
33
+ | `RESULT_READONLY` | `https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly` | `results` |
34
+ | `ALL` | all of the above | |
35
+
36
+ ### 2. Update the Developer Key
37
+
38
+ Existing installs keep the scopes stored on their Developer Key. After changing `lti_scopes`,
39
+ push the new configuration to Canvas, for example with
40
+ `org.install_lti(host: ..., exists: :update)`, or edit the Developer Key's scopes by hand.
41
+
42
+ Canvas only includes the AGS claim in a launch when the Developer Key has the scopes and the
43
+ launch comes from a graded placement (an assignment, or a Deep Linking item created with a
44
+ `lineItem`).
45
+
46
+ ## Posting a score
47
+
48
+ Include `PandaPal::AgsHelpers` in the controller that handles the launched session, then call
49
+ `ags_client`:
50
+
51
+ ```ruby
52
+ class QuizController < ApplicationController
53
+ include PandaPal::AgsHelpers
54
+
55
+ def submit
56
+ ags_client.post_score(score_given: 8, score_maximum: 10)
57
+ rescue PandaPal::LtiServiceError => e
58
+ Rails.logger.error("AGS passback failed: #{e.message} (#{e.status})")
59
+ render plain: 'Could not record your grade', status: :bad_gateway
60
+ end
61
+ end
62
+ ```
63
+
64
+ `ags_client` is `current_session.ags_client`, so the same client is available anywhere you hold a
65
+ `PandaPal::Session`.
66
+
67
+ `post_score` sends a `POST <lineitem>/scores` with `Content-Type:
68
+ application/vnd.ims.lis.v1.score+json` and the body:
69
+
70
+ ```json
71
+ {
72
+ "userId": "<the launch sub>",
73
+ "scoreGiven": 8,
74
+ "scoreMaximum": 10,
75
+ "activityProgress": "Completed",
76
+ "gradingProgress": "FullyGraded",
77
+ "timestamp": "2026-09-30T12:00:00.123Z"
78
+ }
79
+ ```
80
+
81
+ Options:
82
+
83
+ | Keyword | Default | Notes |
84
+ |---------|---------|-------|
85
+ | `score_given` | `nil` | Omitted from the body when `nil` (for example to report progress only). |
86
+ | `score_maximum` | `nil` | Required when `score_given` is set. |
87
+ | `user_id` | the launch `sub` | The AGS `userId`. |
88
+ | `lineitem` | the launch `lineitem` | Any line item URL; its query string is kept. |
89
+ | `activity_progress` | `'Completed'` | `Initialized`, `Started`, `InProgress`, `Submitted`, `Completed`. |
90
+ | `grading_progress` | `'FullyGraded'` | `FullyGraded`, `Pending`, `PendingManual`, `Failed`, `NotReady`. |
91
+ | `comment` | `nil` | Omitted when `nil`. |
92
+ | `timestamp` | `Time.now` | Sent as UTC ISO 8601 with milliseconds. |
93
+ | any other keyword | | Merged into the body as is, for example Canvas's `'https://canvas.instructure.com/lti/submission': { new_submission: true }`. |
94
+
95
+ It returns the parsed JSON response (Canvas answers with a `resultUrl`), or `nil` for an empty body.
96
+
97
+ ## Reading line items and results
98
+
99
+ ```ruby
100
+ ags_client.line_items # GET <lineitems>, every page
101
+ ags_client.line_items(resource_link_id: 'abc', tag: 'grade', limit: 50)
102
+ ags_client.line_item # GET <launch lineitem>
103
+ ags_client.line_item('https://canvas.example.com/api/lti/courses/1/line_items/9')
104
+ ags_client.results # GET <lineitem>/results, every page
105
+ ags_client.results(user_id: 'sub-uuid', limit: 10)
106
+ ```
107
+
108
+ The list calls follow `Link: <...>; rel="next"` headers and return one array. A `next` link on a
109
+ different scheme, host or port than the first page is not followed, so the service token is
110
+ never sent off the platform. Paging stops after 100 pages.
111
+
112
+ `line_items` and `line_item` use the `lineitem.readonly` scope when the launch granted it and
113
+ fall back to `lineitem`.
114
+
115
+ ## Outside a request (background jobs)
116
+
117
+ The client needs the platform, the client_id and the AGS claim. A session is the simplest thing
118
+ to hand a job:
119
+
120
+ ```ruby
121
+ class PostScoreJob < ApplicationJob
122
+ def perform(session_id, score)
123
+ session = PandaPal::Session.find(session_id)
124
+ session.ags_client.post_score(score_given: score, score_maximum: 100)
125
+ end
126
+ end
127
+ ```
128
+
129
+ Or build it from stored launch claims, or entirely from explicit values:
130
+
131
+ ```ruby
132
+ PandaPal::AgsClient.from_launch(launch_params, platform: platform)
133
+
134
+ PandaPal::AgsClient.new(
135
+ platform: PandaPal::Platform::Canvas.new(iss: 'https://canvas.instructure.com'),
136
+ client_id: '10000000000042',
137
+ scopes: [PandaPal::LtiConstants::AgsScopes::SCORE],
138
+ lineitem: 'https://canvas.example.com/api/lti/courses/1/line_items/7',
139
+ user_id: 'sub-uuid',
140
+ )
141
+ ```
142
+
143
+ `scopes` is required: the client refuses any call the scopes do not cover, before it requests a
144
+ token.
145
+
146
+ ## Service access tokens
147
+
148
+ Every AGS call is authorized with an OAuth 2 client_credentials token from the platform's
149
+ `grant_url` (`/login/oauth2/token` on Canvas). `PandaPal::Platform` handles this, and it is
150
+ usable for other LTI Advantage services too:
151
+
152
+ ```ruby
153
+ platform = current_session.lti_platform
154
+ platform.service_access_token(client_id: client_id, scopes: [scope]) # String
155
+ platform.service_access_token(client_id: client_id, scopes: [scope], force: true)
156
+ platform.expire_service_access_token(client_id: client_id, scopes: [scope])
157
+ platform.service_request(:get, url, client_id: client_id, scopes: [scope], headers: { 'Accept' => '...' })
158
+ ```
159
+
160
+ - The token request posts `grant_type=client_credentials`,
161
+ `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, the requested
162
+ scopes space-separated, and a `client_assertion` JWT (`iss` and `sub` are the client_id, `aud`
163
+ is the `grant_url`, with `iat`, `exp` five minutes later and a random `jti`). The assertion is
164
+ signed RS256 with `PandaPal.lti_private_key`, with `kid` set to `PandaPal.jwk.kid`, so it
165
+ verifies against the tool's `/v1p3/public_jwks`.
166
+ - Tokens are cached in `Rails.cache` for `expires_in` less one minute (one hour less a minute when
167
+ the platform omits `expires_in`), keyed by a SHA-256 of the grant URL, client_id and scope set.
168
+ Use a shared cache store (Redis, Memcached) in production so every process reuses the token.
169
+ - `service_request` sends `Authorization: Bearer <token>`. When a cached token is answered with a
170
+ 401, it drops that token, fetches a new one and retries once. A freshly issued token that gets a
171
+ 401 is not retried.
172
+ - Every request is bounded by `Platform::SERVICE_HTTP_OPTIONS` (5 second open, 15 second read and
173
+ write timeouts). Network failures raise `PandaPal::LtiServiceError`.
174
+
175
+ ## Errors
176
+
177
+ All errors descend from `PandaPal::LtiServiceError`, which carries the HTTP `status` when there
178
+ was one. Messages never include the token, the client assertion or the response body.
179
+
180
+ | Error | Raised when |
181
+ |-------|-------------|
182
+ | `PandaPal::AgsClient::NotGranted` | The launch did not grant the scope, or carried no `lineitem` / `lineitems` URL, or there is no user id. Raised before any HTTP request. |
183
+ | `PandaPal::AgsClient::Error` | No platform or client_id, or an AGS URL that is not http(s). |
184
+ | `PandaPal::AgsClient::RequestFailed` | An AGS call answered non-2xx, or with a body that is not JSON. |
185
+ | `PandaPal::Platform::ServiceTokenError` | The token request answered non-2xx, or without an `access_token`. |
186
+ | `PandaPal::LtiServiceError` | A timeout, connection or TLS failure. |
187
+
188
+ ## Troubleshooting
189
+
190
+ - **`NotGranted` on every call**: the launch has no `https://purl.imsglobal.org/spec/lti-ags/claim/endpoint`
191
+ claim. Check that the Developer Key has the scopes (see `/v1p3/config`) and that the tool was
192
+ launched from an assignment.
193
+ - **`ServiceTokenError` with status 400 or 401**: the Developer Key does not allow the requested
194
+ scope, the client_id is wrong, or Canvas cannot verify the assertion against the tool's public
195
+ JWKS (check `LTI_PRIVATE_KEY` matches the key Canvas has).
196
+ - **`RequestFailed` on `post_score`**: check `e.status` and the platform logs. The `userId` must be
197
+ the launch `sub` of a user enrolled in the course, and `scoreMaximum` must accompany `scoreGiven`.
data/README.md CHANGED
@@ -213,6 +213,29 @@ lti_nav content_launch: 'content#launch'
213
213
 
214
214
  **For complete configuration options, implementation examples, helper method documentation, and troubleshooting, see [DEEP_LINKING.md](DEEP_LINKING.md).**
215
215
 
216
+ ## Assignment and Grade Services (LTI 1.3)
217
+
218
+ PandaPal can post scores and read line items and results through LTI Advantage AGS.
219
+
220
+ 1. **Request the scopes** in your LTI initializer (published in `/v1p3/config`):
221
+ ```ruby
222
+ # config/initializers/lti.rb
223
+ PandaPal.lti_scopes = [PandaPal::LtiConstants::AgsScopes::SCORE]
224
+ ```
225
+
226
+ 2. **Post a score** from a controller serving the launched session:
227
+ ```ruby
228
+ class QuizController < ApplicationController
229
+ include PandaPal::AgsHelpers
230
+
231
+ def submit
232
+ ags_client.post_score(score_given: 8, score_maximum: 10)
233
+ end
234
+ end
235
+ ```
236
+
237
+ **For scopes, line items, results, service tokens, background jobs and error handling, see [AGS.md](AGS.md).**
238
+
216
239
  ## Implementating data segregation
217
240
  This engine uses Apartment to keep data segregated between installations of the implementing LTI tool.
218
241
  By default, it does this by inspecting the path of the request, and matching URLs containing `orgs` or `organizations`,
@@ -105,7 +105,7 @@ module PandaPal
105
105
 
106
106
  config_json = {
107
107
  title: PandaPal.lti_options[:title],
108
- scopes: [],
108
+ scopes: PandaPal.lti_scopes,
109
109
  public_jwk_url: v1p3_public_jwks_url,
110
110
  description: PandaPal.lti_options[:description] || 'PandaPal LTI',
111
111
  target_link_uri: v1p3_resource_link_request_url, #app_url(:resource_link_request, request),
@@ -0,0 +1,197 @@
1
+ module PandaPal
2
+ class AgsClient
3
+ class Error < LtiServiceError; end
4
+ class NotGranted < Error; end
5
+ class RequestFailed < Error; end
6
+
7
+ SCOPES = LtiConstants::AgsScopes
8
+ MEDIA_TYPES = LtiConstants::AgsMediaTypes
9
+ MAX_PAGES = 100
10
+
11
+ attr_reader :platform, :client_id, :lineitem, :lineitems, :scopes, :user_id
12
+
13
+ def self.for_session(session)
14
+ launch = (session.launch_params || {}).to_h.with_indifferent_access
15
+ org_client_id = session.panda_pal_organization&.key.to_s.split('/').first
16
+
17
+ from_launch(launch, platform: session.lti_platform, client_id: launch_client_id(launch).presence || org_client_id)
18
+ end
19
+
20
+ def self.from_launch(launch, platform:, client_id: nil)
21
+ launch = launch.to_h.with_indifferent_access
22
+ endpoint = launch[LtiConstants::Ags::ENDPOINT] || {}
23
+
24
+ new(
25
+ platform: platform,
26
+ client_id: client_id.presence || launch_client_id(launch),
27
+ lineitem: endpoint['lineitem'],
28
+ lineitems: endpoint['lineitems'],
29
+ scopes: endpoint['scope'],
30
+ user_id: launch['sub'],
31
+ )
32
+ end
33
+
34
+ def self.launch_client_id(launch)
35
+ launch['azp'].presence || Array(launch['aud']).first
36
+ end
37
+
38
+ def initialize(platform:, client_id:, scopes:, lineitem: nil, lineitems: nil, user_id: nil)
39
+ @platform = platform
40
+ @client_id = client_id
41
+ @lineitem = lineitem.presence
42
+ @lineitems = lineitems.presence
43
+ @scopes = Array(scopes).map(&:to_s)
44
+ @user_id = user_id
45
+ end
46
+
47
+ def granted?(*required)
48
+ required.flatten.all? { |scope| scopes.include?(scope) }
49
+ end
50
+
51
+ def post_score(score_given: nil, score_maximum: nil, user_id: self.user_id, lineitem: self.lineitem,
52
+ activity_progress: 'Completed', grading_progress: 'FullyGraded', comment: nil,
53
+ timestamp: Time.now, **extra)
54
+ require_scope!(SCOPES::SCORE)
55
+ raise ArgumentError, 'score_maximum is required when score_given is set' if !score_given.nil? && score_maximum.nil?
56
+ raise NotGranted, 'no AGS userId: pass user_id or launch with a sub claim' if user_id.blank?
57
+
58
+ body = {
59
+ userId: user_id,
60
+ scoreGiven: score_given,
61
+ scoreMaximum: score_maximum,
62
+ activityProgress: activity_progress,
63
+ gradingProgress: grading_progress,
64
+ timestamp: timestamp.to_time.getutc.iso8601(3),
65
+ comment: comment,
66
+ }.compact.merge(extra)
67
+
68
+ response = request(:post, child_url(require_lineitem!(lineitem), 'scores'), SCOPES::SCORE,
69
+ headers: { 'Content-Type' => MEDIA_TYPES::SCORE },
70
+ body: JSON.generate(body))
71
+ parse(response)
72
+ end
73
+
74
+ def line_items(**filters)
75
+ scope = read_line_item_scope!
76
+ raise NotGranted, 'launch carried no AGS lineitems URL' if lineitems.blank?
77
+
78
+ paginate(with_query(lineitems, filters), scope, MEDIA_TYPES::LINEITEM_CONTAINER)
79
+ end
80
+
81
+ def line_item(url = lineitem)
82
+ scope = read_line_item_scope!
83
+ response = request(:get, validate_url!(require_lineitem!(url)).to_s, scope,
84
+ headers: { 'Accept' => MEDIA_TYPES::LINEITEM })
85
+ parse(response)
86
+ end
87
+
88
+ def results(lineitem: self.lineitem, user_id: nil, limit: nil)
89
+ require_scope!(SCOPES::RESULT_READONLY)
90
+ url = with_query(child_url(require_lineitem!(lineitem), 'results'), user_id: user_id, limit: limit)
91
+ paginate(url, SCOPES::RESULT_READONLY, MEDIA_TYPES::RESULT_CONTAINER)
92
+ end
93
+
94
+ private
95
+
96
+ def require_scope!(scope)
97
+ raise NotGranted, "launch did not grant the AGS scope #{scope}" unless granted?(scope)
98
+ end
99
+
100
+ def read_line_item_scope!
101
+ return SCOPES::LINEITEM_READONLY if granted?(SCOPES::LINEITEM_READONLY)
102
+ return SCOPES::LINEITEM if granted?(SCOPES::LINEITEM)
103
+
104
+ raise NotGranted, "launch did not grant the AGS scope #{SCOPES::LINEITEM_READONLY} or #{SCOPES::LINEITEM}"
105
+ end
106
+
107
+ def require_lineitem!(url)
108
+ raise NotGranted, 'launch carried no AGS lineitem' if url.blank?
109
+
110
+ url
111
+ end
112
+
113
+ def request(method, url, scope, **options)
114
+ raise Error, 'no LTI platform to request an AGS token from' if platform.nil?
115
+ raise Error, 'no client_id to request an AGS token for' if client_id.blank?
116
+
117
+ response = platform.service_request(method, url, client_id: client_id, scopes: [scope], **options)
118
+ raise RequestFailed.new("AGS #{method.to_s.upcase} answered #{response.code}", status: response.code) unless response.success?
119
+
120
+ response
121
+ end
122
+
123
+ def paginate(url, scope, media_type)
124
+ items = []
125
+ origin = origin_of(url)
126
+
127
+ MAX_PAGES.times do
128
+ response = request(:get, url, scope, headers: { 'Accept' => media_type })
129
+ page = parse(response)
130
+ items.concat(Array.wrap(page))
131
+
132
+ next_url = next_link(response)
133
+ return items if next_url.nil?
134
+
135
+ url = URI.join(url, next_url).to_s
136
+ return items if origin_of(url) != origin
137
+ end
138
+
139
+ items
140
+ end
141
+
142
+ def parse(response)
143
+ body = response.body.to_s
144
+ return nil if body.strip.empty?
145
+
146
+ JSON.parse(body)
147
+ rescue JSON::ParserError
148
+ raise RequestFailed.new('AGS response was not JSON', status: response.code)
149
+ end
150
+
151
+ def next_link(response)
152
+ header = response.headers && response.headers['link']
153
+ return nil if header.blank?
154
+
155
+ Array(header).join(',').split(',').each do |part|
156
+ match = part.match(/<([^>]+)>\s*;(.*)/)
157
+ next unless match && match[2] =~ /rel="?next"?/
158
+
159
+ return match[1]
160
+ end
161
+
162
+ nil
163
+ end
164
+
165
+ def child_url(url, segment)
166
+ uri = validate_url!(url)
167
+ uri.path = "#{uri.path.chomp('/')}/#{segment}"
168
+ uri.to_s
169
+ end
170
+
171
+ def with_query(url, params)
172
+ params = params.compact.transform_keys(&:to_s)
173
+ uri = validate_url!(url)
174
+ return uri.to_s if params.empty?
175
+
176
+ query = Rack::Utils.parse_query(uri.query).merge(params.transform_values(&:to_s))
177
+ uri.query = Rack::Utils.build_query(query)
178
+ uri.to_s
179
+ end
180
+
181
+ def validate_url!(url)
182
+ uri = URI.parse(url.to_s)
183
+ raise Error, 'AGS URL is not an http(s) URL' unless uri.is_a?(URI::HTTP) && uri.host.present?
184
+
185
+ uri
186
+ rescue URI::InvalidURIError
187
+ raise Error, 'AGS URL is not an http(s) URL'
188
+ end
189
+
190
+ def origin_of(url)
191
+ uri = URI.parse(url.to_s)
192
+ [uri.scheme, uri.host, uri.port]
193
+ rescue URI::InvalidURIError
194
+ nil
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,7 @@
1
+ module PandaPal
2
+ module AgsHelpers
3
+ def ags_client
4
+ current_session.ags_client
5
+ end
6
+ end
7
+ end
@@ -23,6 +23,35 @@ module PandaPal
23
23
  nil
24
24
  end
25
25
 
26
+ class ServiceTokenError < LtiServiceError; end
27
+
28
+ CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'.freeze
29
+ CLIENT_ASSERTION_LIFETIME = 5.minutes
30
+ SERVICE_TOKEN_DEFAULT_LIFETIME = 1.hour
31
+ SERVICE_TOKEN_EXPIRY_MARGIN = 1.minute
32
+ SERVICE_HTTP_OPTIONS = { open_timeout: 5, read_timeout: 15, write_timeout: 15 }.freeze
33
+ SERVICE_NETWORK_ERRORS = [
34
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, SocketError, SystemCallError,
35
+ OpenSSL::SSL::SSLError, HTTParty::Error,
36
+ ].freeze
37
+
38
+ def service_access_token(client_id:, scopes:, force: false)
39
+ fetch_service_access_token(client_id, scopes, force: force).first
40
+ end
41
+
42
+ def expire_service_access_token(client_id:, scopes:)
43
+ Rails.cache.delete(service_token_cache_key(client_id, scopes))
44
+ end
45
+
46
+ def service_request(method, url, client_id:, scopes:, headers: {}, **options)
47
+ token, cached = fetch_service_access_token(client_id, scopes)
48
+ response = authorized_service_request(method, url, token, headers, options)
49
+ return response unless response.code == 401 && cached
50
+
51
+ token, = fetch_service_access_token(client_id, scopes, force: true)
52
+ authorized_service_request(method, url, token, headers, options)
53
+ end
54
+
26
55
  def self.from_serialized(ser)
27
56
  cls = ser[:platform_class].safe_constantize
28
57
  cls.deserialize(ser)
@@ -83,6 +112,81 @@ module PandaPal
83
112
  end
84
113
  end
85
114
 
115
+ private
116
+
117
+ def fetch_service_access_token(client_id, scopes, force: false)
118
+ cache_key = service_token_cache_key(client_id, scopes)
119
+
120
+ unless force
121
+ cached = Rails.cache.read(cache_key)
122
+ return [cached, true] if cached.present?
123
+ end
124
+
125
+ [request_service_access_token(client_id, scopes, cache_key), false]
126
+ end
127
+
128
+ def request_service_access_token(client_id, scopes, cache_key)
129
+ response = http_service_request(:post, grant_url, 'token request', body: {
130
+ grant_type: 'client_credentials',
131
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
132
+ client_assertion: service_client_assertion(client_id),
133
+ scope: normalize_service_scopes(scopes).join(' '),
134
+ })
135
+
136
+ raise ServiceTokenError.new("token request answered #{response.code}", status: response.code) unless response.success?
137
+
138
+ parsed = parse_service_json(response.body)
139
+ token = parsed['access_token'] if parsed.is_a?(Hash)
140
+ raise ServiceTokenError.new('token response carried no access_token', status: response.code) unless token.is_a?(String) && token.present?
141
+
142
+ expires_in = parsed['expires_in'].to_i
143
+ lifetime = expires_in.positive? ? expires_in.seconds : SERVICE_TOKEN_DEFAULT_LIFETIME
144
+ ttl = lifetime - SERVICE_TOKEN_EXPIRY_MARGIN
145
+ Rails.cache.write(cache_key, token, expires_in: ttl) if ttl.positive?
146
+
147
+ token
148
+ end
149
+
150
+ def authorized_service_request(method, url, token, headers, options)
151
+ http_service_request(method, url, "#{method.to_s.upcase} request",
152
+ headers: headers.merge('Authorization' => "Bearer #{token}"), **options)
153
+ end
154
+
155
+ def http_service_request(method, url, label, **options)
156
+ HTTParty.send(method, url, **SERVICE_HTTP_OPTIONS, **options)
157
+ rescue *SERVICE_NETWORK_ERRORS => e
158
+ raise LtiServiceError, "#{label} failed: #{e.class}"
159
+ end
160
+
161
+ def parse_service_json(body)
162
+ JSON.parse(body.to_s)
163
+ rescue JSON::ParserError
164
+ nil
165
+ end
166
+
167
+ def service_client_assertion(client_id)
168
+ jwk = PandaPal.jwk
169
+ now = Time.now.to_i
170
+ claims = {
171
+ iss: client_id,
172
+ sub: client_id,
173
+ aud: grant_url,
174
+ iat: now,
175
+ exp: now + CLIENT_ASSERTION_LIFETIME.to_i,
176
+ jti: SecureRandom.uuid,
177
+ }
178
+ JWT.encode(claims, jwk.signing_key, jwk[:alg], kid: jwk[:kid])
179
+ end
180
+
181
+ def normalize_service_scopes(scopes)
182
+ Array(scopes).map(&:to_s).reject(&:blank?).uniq.sort
183
+ end
184
+
185
+ def service_token_cache_key(client_id, scopes)
186
+ digest = Digest::SHA256.hexdigest([grant_url, client_id.to_s, *normalize_service_scopes(scopes)].join("\n"))
187
+ "panda_pal/lti_service_tokens/#{digest}"
188
+ end
189
+
86
190
  protected
87
191
 
88
192
  def self.find_org_setting(paths, org = current_organization)
@@ -203,6 +203,10 @@ module PandaPal
203
203
  @lti_platform ||= Platform.from_serialized(data[:lti_platform])
204
204
  end
205
205
 
206
+ def ags_client
207
+ @ags_client ||= AgsClient.for_session(self)
208
+ end
209
+
206
210
  def lti_launch_placement
207
211
  launch_params['https://www.instructure.com/placement'] || launch_params[:launch_type]
208
212
  end
@@ -19,6 +19,25 @@ module PandaPal
19
19
  DEEP_LINKING_SETTINGS = 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'.freeze
20
20
  end
21
21
 
22
+ module Ags
23
+ ENDPOINT = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'.freeze
24
+ end
25
+
26
+ module AgsScopes
27
+ LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'.freeze
28
+ LINEITEM_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'.freeze
29
+ RESULT_READONLY = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'.freeze
30
+ SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'.freeze
31
+ ALL = [LINEITEM, LINEITEM_READONLY, RESULT_READONLY, SCORE].freeze
32
+ end
33
+
34
+ module AgsMediaTypes
35
+ LINEITEM = 'application/vnd.ims.lis.v2.lineitem+json'.freeze
36
+ LINEITEM_CONTAINER = 'application/vnd.ims.lis.v2.lineitemcontainer+json'.freeze
37
+ RESULT_CONTAINER = 'application/vnd.ims.lis.v2.resultcontainer+json'.freeze
38
+ SCORE = 'application/vnd.ims.lis.v1.score+json'.freeze
39
+ end
40
+
22
41
  # Canvas-specific Claims
23
42
  module Canvas
24
43
  PLACEMENT = 'https://www.instructure.com/placement'.freeze
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.17.2'
2
+ VERSION = '5.18.0'
3
3
  end
data/lib/panda_pal.rb CHANGED
@@ -13,11 +13,21 @@ module PandaPal
13
13
  class LtiNavigationInUse < StandardError;end
14
14
  class NotMounted < StandardError;end
15
15
 
16
+ class LtiServiceError < StandardError
17
+ attr_reader :status
18
+
19
+ def initialize(message = nil, status: nil)
20
+ super(message)
21
+ @status = status
22
+ end
23
+ end
24
+
16
25
  @@lti_navigation = {}
17
26
  @@lti_options = {}
18
27
  @@lti_properties = {}
19
28
  @@lti_environments = {}
20
29
  @@lti_custom_params = {}
30
+ @@lti_scopes = []
21
31
  @@extensions = {}
22
32
  @@lti_private_key = nil
23
33
 
@@ -53,6 +63,14 @@ module PandaPal
53
63
  @@lti_custom_params.deep_dup
54
64
  end
55
65
 
66
+ def self.lti_scopes=(scopes)
67
+ @@lti_scopes = Array(scopes).map(&:to_s)
68
+ end
69
+
70
+ def self.lti_scopes
71
+ @@lti_scopes.deep_dup
72
+ end
73
+
56
74
  def self.stage_navigation(navigation, options = {})
57
75
  warn "[DEPRECATION] `stage_navigation` is deprecated. Please use `stage_placement` instead."
58
76
  stage_placement(navigation, options)
data/panda_pal.gemspec CHANGED
@@ -17,7 +17,7 @@ Gem::Specification.new do |s|
17
17
  s.summary = "LTI mountable engine"
18
18
  s.license = "MIT"
19
19
 
20
- s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "panda_pal.gemspec"]
20
+ s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "AGS.md", "panda_pal.gemspec"]
21
21
  s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
22
22
 
23
23
  s.add_dependency "rails", ">= 4.2"
@@ -75,4 +75,34 @@ RSpec.describe "PandaPal::LtiV1P3Controller", type: :request do
75
75
  expect(hidden_fields['nonce']).not_to be_blank
76
76
  end
77
77
  end
78
+
79
+ describe 'GET /v1p3/config' do
80
+ before { PandaPal.lti_environments = { domain: 'tool.example.com' } }
81
+
82
+ after do
83
+ PandaPal.lti_environments = {}
84
+ PandaPal.lti_scopes = []
85
+ end
86
+
87
+ it 'publishes no scopes by default' do
88
+ get "/panda_pal/v1p3/config"
89
+
90
+ expect(response).to have_http_status(:ok)
91
+ expect(JSON.parse(response.body)['scopes']).to eq([])
92
+ end
93
+
94
+ it 'publishes the configured PandaPal.lti_scopes' do
95
+ PandaPal.lti_scopes = [
96
+ PandaPal::LtiConstants::AgsScopes::SCORE,
97
+ PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
98
+ ]
99
+
100
+ get "/panda_pal/v1p3/config"
101
+
102
+ expect(JSON.parse(response.body)['scopes']).to eq([
103
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/score',
104
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
105
+ ])
106
+ end
107
+ end
78
108
  end
@@ -0,0 +1,385 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe PandaPal::AgsClient do
4
+ let(:platform) { PandaPal::Platform::Canvas.new(iss: 'https://canvas.instructure.com') }
5
+ let(:client_id) { '10000000000042' }
6
+ let(:lineitem) { 'https://canvas.example.com/api/lti/courses/1/line_items/7' }
7
+ let(:lineitems) { 'https://canvas.example.com/api/lti/courses/1/line_items' }
8
+ let(:scopes) { PandaPal::LtiConstants::AgsScopes::ALL }
9
+ let(:user_id) { 'launch-sub-uuid' }
10
+
11
+ subject(:client) do
12
+ described_class.new(platform: platform, client_id: client_id, scopes: scopes,
13
+ lineitem: lineitem, lineitems: lineitems, user_id: user_id)
14
+ end
15
+
16
+ def http_response(code, body = '', headers = {})
17
+ double('HTTParty::Response', code: code, success?: (200..299).cover?(code), body: body, headers: headers)
18
+ end
19
+
20
+ def expect_service_request(method, url, scope, response, **options)
21
+ expect(platform).to receive(:service_request)
22
+ .with(method, url, client_id: client_id, scopes: [scope], **options)
23
+ .and_return(response)
24
+ end
25
+
26
+ describe '.from_launch' do
27
+ let(:launch) do
28
+ {
29
+ 'sub' => user_id,
30
+ 'aud' => client_id,
31
+ PandaPal::LtiConstants::Ags::ENDPOINT => {
32
+ 'scope' => [PandaPal::LtiConstants::AgsScopes::SCORE],
33
+ 'lineitem' => lineitem,
34
+ 'lineitems' => lineitems,
35
+ },
36
+ }
37
+ end
38
+
39
+ it 'reads the AGS endpoint claim, the sub and the client_id' do
40
+ built = described_class.from_launch(launch, platform: platform)
41
+
42
+ expect(built.platform).to eq(platform)
43
+ expect(built.client_id).to eq(client_id)
44
+ expect(built.user_id).to eq(user_id)
45
+ expect(built.lineitem).to eq(lineitem)
46
+ expect(built.lineitems).to eq(lineitems)
47
+ expect(built.scopes).to eq([PandaPal::LtiConstants::AgsScopes::SCORE])
48
+ end
49
+
50
+ it 'prefers azp over an aud array' do
51
+ built = described_class.from_launch(launch.merge('aud' => ['other', client_id], 'azp' => client_id), platform: platform)
52
+
53
+ expect(built.client_id).to eq(client_id)
54
+ end
55
+
56
+ it 'grants nothing when the launch has no AGS claim' do
57
+ built = described_class.from_launch(launch.except(PandaPal::LtiConstants::Ags::ENDPOINT), platform: platform)
58
+
59
+ expect(built.scopes).to eq([])
60
+ expect(built.lineitem).to be_nil
61
+ expect { built.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::NotGranted)
62
+ end
63
+ end
64
+
65
+ describe '.for_session and Session#ags_client' do
66
+ let(:organization) { create(:panda_pal_organization, key: "#{client_id}/deployment-1") }
67
+ let(:session) do
68
+ PandaPal::Session.new(panda_pal_organization: organization, data: {
69
+ lti_platform: platform.serialize,
70
+ launch_params: {
71
+ 'sub' => user_id,
72
+ 'aud' => client_id,
73
+ PandaPal::LtiConstants::Ags::ENDPOINT => { 'scope' => scopes, 'lineitem' => lineitem, 'lineitems' => lineitems },
74
+ },
75
+ })
76
+ end
77
+
78
+ it 'builds a client from the session launch_params and lti_platform' do
79
+ built = session.ags_client
80
+
81
+ expect(built).to be_a(described_class)
82
+ expect(built.platform).to be_a(PandaPal::Platform::Canvas)
83
+ expect(built.platform.grant_url).to eq(platform.grant_url)
84
+ expect(built.client_id).to eq(client_id)
85
+ expect(built.lineitem).to eq(lineitem)
86
+ expect(built.user_id).to eq(user_id)
87
+ expect(built.scopes).to eq(scopes)
88
+ expect(session.ags_client).to be(built)
89
+ end
90
+
91
+ it 'falls back to the client_id half of the organization key when the launch has no aud' do
92
+ session.data[:launch_params].delete('aud')
93
+
94
+ expect(described_class.for_session(session).client_id).to eq(client_id)
95
+ end
96
+
97
+ it 'is exposed to controllers through PandaPal::AgsHelpers' do
98
+ host = Class.new do
99
+ include PandaPal::AgsHelpers
100
+
101
+ attr_reader :current_session
102
+
103
+ def initialize(session)
104
+ @current_session = session
105
+ end
106
+ end
107
+
108
+ expect(host.new(session).ags_client).to be(session.ags_client)
109
+ end
110
+ end
111
+
112
+ describe '#post_score' do
113
+ let(:scores_url) { "#{lineitem}/scores" }
114
+
115
+ it 'posts the AGS score media type to the lineitem scores endpoint for the launch sub' do
116
+ captured = nil
117
+ expect(platform).to receive(:service_request) do |method, url, **options|
118
+ captured = { method: method, url: url, options: options }
119
+ http_response(200, '{"resultUrl":"https://canvas.example.com/results/1"}')
120
+ end
121
+
122
+ timestamp = Time.utc(2026, 9, 30, 12, 0, 0, 123_456)
123
+ result = client.post_score(score_given: 8, score_maximum: 10, timestamp: timestamp)
124
+
125
+ expect(result).to eq('resultUrl' => 'https://canvas.example.com/results/1')
126
+ expect(captured[:method]).to eq(:post)
127
+ expect(captured[:url]).to eq(scores_url)
128
+ expect(captured[:options][:client_id]).to eq(client_id)
129
+ expect(captured[:options][:scopes]).to eq([PandaPal::LtiConstants::AgsScopes::SCORE])
130
+ expect(captured[:options][:headers]).to eq('Content-Type' => 'application/vnd.ims.lis.v1.score+json')
131
+ expect(JSON.parse(captured[:options][:body])).to eq(
132
+ 'userId' => user_id,
133
+ 'scoreGiven' => 8,
134
+ 'scoreMaximum' => 10,
135
+ 'activityProgress' => 'Completed',
136
+ 'gradingProgress' => 'FullyGraded',
137
+ 'timestamp' => '2026-09-30T12:00:00.123Z',
138
+ )
139
+ end
140
+
141
+ it 'includes a comment, overrides and extra fields when given' do
142
+ other_user = SecureRandom.uuid
143
+ body = nil
144
+ allow(platform).to receive(:service_request) { |*, **options| body = JSON.parse(options[:body]); http_response(204) }
145
+
146
+ result = client.post_score(score_given: 1, score_maximum: 1, comment: 'Well done', user_id: other_user,
147
+ activity_progress: 'Submitted', grading_progress: 'Pending',
148
+ 'https://canvas.instructure.com/lti/submission': { new_submission: true })
149
+
150
+ expect(result).to be_nil
151
+ expect(body).to include(
152
+ 'userId' => other_user,
153
+ 'comment' => 'Well done',
154
+ 'activityProgress' => 'Submitted',
155
+ 'gradingProgress' => 'Pending',
156
+ 'https://canvas.instructure.com/lti/submission' => { 'new_submission' => true },
157
+ )
158
+ end
159
+
160
+ it 'omits scoreGiven and scoreMaximum when no score is given' do
161
+ body = nil
162
+ allow(platform).to receive(:service_request) { |*, **options| body = JSON.parse(options[:body]); http_response(200, '{}') }
163
+
164
+ client.post_score(activity_progress: 'Started', grading_progress: 'NotReady')
165
+
166
+ expect(body.keys).not_to include('scoreGiven', 'scoreMaximum')
167
+ end
168
+
169
+ it 'keeps a lineitem query string after the scores segment' do
170
+ with_query = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id,
171
+ lineitem: 'https://canvas.example.com/api/lti/courses/1/line_items/7?type_id=abc&x=1')
172
+ expect(platform).to receive(:service_request)
173
+ .with(:post, 'https://canvas.example.com/api/lti/courses/1/line_items/7/scores?type_id=abc&x=1', anything)
174
+ .and_return(http_response(200, '{}'))
175
+
176
+ with_query.post_score(score_given: 1, score_maximum: 1)
177
+ end
178
+
179
+ it 'does not double a trailing slash' do
180
+ expect(platform).to receive(:service_request).with(:post, scores_url, anything).and_return(http_response(200, '{}'))
181
+
182
+ described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: "#{lineitem}/")
183
+ .post_score(score_given: 1, score_maximum: 1)
184
+ end
185
+
186
+ it 'refuses before any request when the score scope was not granted' do
187
+ expect(platform).not_to receive(:service_request)
188
+
189
+ limited = described_class.new(platform: platform, client_id: client_id, lineitem: lineitem, user_id: user_id,
190
+ scopes: [PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY])
191
+
192
+ expect { limited.post_score(score_given: 1, score_maximum: 1) }
193
+ .to raise_error(described_class::NotGranted, %r{scope/score})
194
+ end
195
+
196
+ it 'refuses before any request when the launch carried no lineitem' do
197
+ expect(platform).not_to receive(:service_request)
198
+
199
+ no_lineitem = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id)
200
+
201
+ expect { no_lineitem.post_score(score_given: 1, score_maximum: 1) }
202
+ .to raise_error(described_class::NotGranted, /no AGS lineitem/)
203
+ end
204
+
205
+ it 'refuses a lineitem that is not an http(s) URL' do
206
+ expect(platform).not_to receive(:service_request)
207
+
208
+ bad = described_class.new(platform: platform, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: 'javascript:alert(1)')
209
+
210
+ expect { bad.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::Error, /http\(s\)/)
211
+ end
212
+
213
+ it 'refuses when there is no user id' do
214
+ expect(platform).not_to receive(:service_request)
215
+
216
+ expect { client.post_score(score_given: 1, score_maximum: 1, user_id: nil) }
217
+ .to raise_error(described_class::NotGranted, /userId/)
218
+ end
219
+
220
+ it 'requires score_maximum alongside score_given' do
221
+ expect { client.post_score(score_given: 1) }.to raise_error(ArgumentError)
222
+ end
223
+
224
+ it 'refuses when there is no platform' do
225
+ no_platform = described_class.new(platform: nil, client_id: client_id, scopes: scopes, user_id: user_id, lineitem: lineitem)
226
+
227
+ expect { no_platform.post_score(score_given: 1, score_maximum: 1) }.to raise_error(described_class::Error, /platform/)
228
+ end
229
+
230
+ it 'raises RequestFailed with the status on a non-2xx answer' do
231
+ allow(platform).to receive(:service_request).and_return(http_response(422, '{"errors":"bad"}'))
232
+
233
+ expect { client.post_score(score_given: 1, score_maximum: 1) }
234
+ .to raise_error(described_class::RequestFailed) { |error|
235
+ expect(error.status).to eq(422)
236
+ expect(error).to be_a(PandaPal::LtiServiceError)
237
+ }
238
+ end
239
+ end
240
+
241
+ describe '#line_items' do
242
+ let(:container) { PandaPal::LtiConstants::AgsMediaTypes::LINEITEM_CONTAINER }
243
+ let(:readonly) { PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY }
244
+
245
+ it 'lists line items with the container media type and the read-only scope' do
246
+ expect_service_request(:get, lineitems, readonly,
247
+ http_response(200, [{ 'id' => lineitem, 'label' => 'Quiz' }].to_json),
248
+ headers: { 'Accept' => container })
249
+
250
+ expect(client.line_items).to eq([{ 'id' => lineitem, 'label' => 'Quiz' }])
251
+ end
252
+
253
+ it 'adds filters to the query string' do
254
+ expect_service_request(:get, "#{lineitems}?resource_link_id=rl-1&tag=grade", readonly,
255
+ http_response(200, '[]'), headers: { 'Accept' => container })
256
+
257
+ expect(client.line_items(resource_link_id: 'rl-1', tag: 'grade', resource_id: nil)).to eq([])
258
+ end
259
+
260
+ it 'follows rel="next" links on the same origin' do
261
+ page_two = "#{lineitems}?page=2"
262
+ expect_service_request(:get, lineitems, readonly,
263
+ http_response(200, '[{"id":"a"}]', { 'link' => %(<#{page_two}>; rel="next", <#{lineitems}?page=9>; rel="last") }),
264
+ headers: { 'Accept' => container })
265
+ expect_service_request(:get, page_two, readonly, http_response(200, '[{"id":"b"}]'), headers: { 'Accept' => container })
266
+
267
+ expect(client.line_items.map { |item| item['id'] }).to eq(%w[a b])
268
+ end
269
+
270
+ it 'does not follow a next link to another origin' do
271
+ expect_service_request(:get, lineitems, readonly,
272
+ http_response(200, '[{"id":"a"}]', { 'link' => '<https://attacker.example.com/steal>; rel="next"' }),
273
+ headers: { 'Accept' => container })
274
+
275
+ expect(client.line_items.map { |item| item['id'] }).to eq(%w[a])
276
+ end
277
+
278
+ it 'uses the lineitem scope when only that was granted' do
279
+ full = described_class.new(platform: platform, client_id: client_id, lineitems: lineitems,
280
+ scopes: [PandaPal::LtiConstants::AgsScopes::LINEITEM])
281
+ expect_service_request(:get, lineitems, PandaPal::LtiConstants::AgsScopes::LINEITEM,
282
+ http_response(200, '[]'), headers: { 'Accept' => container })
283
+
284
+ full.line_items
285
+ end
286
+
287
+ it 'refuses when no line item scope was granted' do
288
+ expect(platform).not_to receive(:service_request)
289
+
290
+ score_only = described_class.new(platform: platform, client_id: client_id, lineitems: lineitems,
291
+ scopes: [PandaPal::LtiConstants::AgsScopes::SCORE])
292
+
293
+ expect { score_only.line_items }.to raise_error(described_class::NotGranted, /lineitem/)
294
+ end
295
+
296
+ it 'refuses when the launch carried no lineitems URL' do
297
+ expect(platform).not_to receive(:service_request)
298
+
299
+ no_container = described_class.new(platform: platform, client_id: client_id, scopes: scopes)
300
+
301
+ expect { no_container.line_items }.to raise_error(described_class::NotGranted, /lineitems URL/)
302
+ end
303
+ end
304
+
305
+ describe '#line_item' do
306
+ it 'gets the launch lineitem with the lineitem media type' do
307
+ expect_service_request(:get, lineitem, PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
308
+ http_response(200, { 'id' => lineitem, 'scoreMaximum' => 10 }.to_json),
309
+ headers: { 'Accept' => PandaPal::LtiConstants::AgsMediaTypes::LINEITEM })
310
+
311
+ expect(client.line_item).to eq('id' => lineitem, 'scoreMaximum' => 10)
312
+ end
313
+
314
+ it 'gets an explicit lineitem URL' do
315
+ other = "#{lineitems}/99"
316
+ expect_service_request(:get, other, PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
317
+ http_response(200, '{"id":"99"}'), headers: { 'Accept' => PandaPal::LtiConstants::AgsMediaTypes::LINEITEM })
318
+
319
+ expect(client.line_item(other)).to eq('id' => '99')
320
+ end
321
+
322
+ it 'raises RequestFailed when the body is not JSON' do
323
+ allow(platform).to receive(:service_request).and_return(http_response(200, '<html>'))
324
+
325
+ expect { client.line_item }.to raise_error(described_class::RequestFailed, /not JSON/)
326
+ end
327
+ end
328
+
329
+ describe '#results' do
330
+ let(:results_url) { "#{lineitem}/results" }
331
+ let(:container) { PandaPal::LtiConstants::AgsMediaTypes::RESULT_CONTAINER }
332
+ let(:result_scope) { PandaPal::LtiConstants::AgsScopes::RESULT_READONLY }
333
+
334
+ it 'reads the lineitem results with the result container media type' do
335
+ expect_service_request(:get, results_url, result_scope,
336
+ http_response(200, [{ 'userId' => user_id, 'resultScore' => 8 }].to_json), headers: { 'Accept' => container })
337
+
338
+ expect(client.results).to eq([{ 'userId' => user_id, 'resultScore' => 8 }])
339
+ end
340
+
341
+ it 'filters by user_id and limit, keeping an existing query string' do
342
+ with_query = described_class.new(platform: platform, client_id: client_id, scopes: scopes,
343
+ lineitem: "#{lineitem}?type_id=abc")
344
+ expect_service_request(:get, "#{results_url}?type_id=abc&user_id=#{user_id}&limit=5", result_scope,
345
+ http_response(200, '[]'), headers: { 'Accept' => container })
346
+
347
+ expect(with_query.results(user_id: user_id, limit: 5)).to eq([])
348
+ end
349
+
350
+ it 'refuses when the result scope was not granted' do
351
+ expect(platform).not_to receive(:service_request)
352
+
353
+ score_only = described_class.new(platform: platform, client_id: client_id, lineitem: lineitem,
354
+ scopes: [PandaPal::LtiConstants::AgsScopes::SCORE])
355
+
356
+ expect { score_only.results }.to raise_error(described_class::NotGranted, /result\.readonly/)
357
+ end
358
+ end
359
+
360
+ describe 'end to end through the platform token' do
361
+ let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
362
+
363
+ before do
364
+ PandaPal.lti_private_key = rsa_key
365
+ PandaPal.jwk = nil
366
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
367
+ end
368
+
369
+ after do
370
+ PandaPal.lti_private_key = nil
371
+ PandaPal.jwk = nil
372
+ end
373
+
374
+ it 'requests only the score scope and posts with the issued bearer token' do
375
+ expect(HTTParty).to receive(:post).with(platform.grant_url, hash_including(
376
+ body: hash_including(grant_type: 'client_credentials', scope: PandaPal::LtiConstants::AgsScopes::SCORE)
377
+ )).and_return(http_response(200, '{"access_token":"issued","expires_in":3600}'))
378
+ expect(HTTParty).to receive(:post).with("#{lineitem}/scores", hash_including(
379
+ headers: { 'Content-Type' => 'application/vnd.ims.lis.v1.score+json', 'Authorization' => 'Bearer issued' }
380
+ )).and_return(http_response(200, '{}'))
381
+
382
+ client.post_score(score_given: 1, score_maximum: 1)
383
+ end
384
+ end
385
+ end
@@ -148,5 +148,202 @@ module PandaPal
148
148
  expect(restored.jwks_url).to eq(platform.jwks_url)
149
149
  end
150
150
  end
151
+ describe 'LTI Advantage service tokens' do
152
+ let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
153
+ let(:platform) { Platform::Canvas.new(iss: 'https://canvas.instructure.com') }
154
+ let(:grant_url) { 'https://sso.canvaslms.com/login/oauth2/token' }
155
+ let(:client_id) { '10000000000042' }
156
+ let(:score_scope) { LtiConstants::AgsScopes::SCORE }
157
+ let(:cache) { ActiveSupport::Cache::MemoryStore.new }
158
+
159
+ before do
160
+ PandaPal.lti_private_key = rsa_key
161
+ PandaPal.jwk = nil
162
+ allow(Rails).to receive(:cache).and_return(cache)
163
+ end
164
+
165
+ after do
166
+ PandaPal.lti_private_key = nil
167
+ PandaPal.jwk = nil
168
+ end
169
+
170
+ def http_response(code, body = '', headers = {})
171
+ double('HTTParty::Response', code: code, success?: (200..299).cover?(code), body: body, headers: headers)
172
+ end
173
+
174
+ def token_response(token, expires_in: 3600)
175
+ http_response(200, { access_token: token, token_type: 'Bearer', expires_in: expires_in }.to_json)
176
+ end
177
+
178
+ describe '#service_access_token' do
179
+ it 'posts a client_credentials grant with an RS256 client assertion signed by the tool key' do
180
+ captured = nil
181
+ allow(HTTParty).to receive(:post) do |url, **options|
182
+ captured = { url: url, options: options }
183
+ token_response('service-token')
184
+ end
185
+
186
+ token = platform.service_access_token(client_id: client_id, scopes: [score_scope])
187
+
188
+ expect(token).to eq('service-token')
189
+ expect(captured[:url]).to eq(grant_url)
190
+
191
+ body = captured[:options][:body]
192
+ expect(body[:grant_type]).to eq('client_credentials')
193
+ expect(body[:client_assertion_type]).to eq('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
194
+ expect(body[:scope]).to eq(score_scope)
195
+
196
+ payload, header = JWT.decode(body[:client_assertion], rsa_key.public_key, true, algorithm: 'RS256')
197
+ expect(header['alg']).to eq('RS256')
198
+ expect(header['kid']).to eq(PandaPal.jwk.kid)
199
+ expect(payload['iss']).to eq(client_id)
200
+ expect(payload['sub']).to eq(client_id)
201
+ expect(payload['aud']).to eq(grant_url)
202
+ expect(payload['jti']).to be_present
203
+ expect(payload['exp'] - payload['iat']).to eq(300)
204
+ expect(payload['iat']).to be_within(5).of(Time.now.to_i)
205
+ end
206
+
207
+ it 'joins several scopes with spaces' do
208
+ expect(HTTParty).to receive(:post).with(grant_url, hash_including(
209
+ body: hash_including(scope: [LtiConstants::AgsScopes::LINEITEM_READONLY, score_scope].sort.join(' '))
210
+ )).and_return(token_response('service-token'))
211
+
212
+ platform.service_access_token(client_id: client_id, scopes: [score_scope, LtiConstants::AgsScopes::LINEITEM_READONLY])
213
+ end
214
+
215
+ it 'bounds the request with timeouts' do
216
+ expect(HTTParty).to receive(:post).with(grant_url, hash_including(open_timeout: 5, read_timeout: 15))
217
+ .and_return(token_response('service-token'))
218
+
219
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
220
+ end
221
+
222
+ it 'reuses the cached token for the same client and scopes' do
223
+ expect(HTTParty).to receive(:post).once.and_return(token_response('service-token'))
224
+
225
+ 2.times { expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('service-token') }
226
+ end
227
+
228
+ it 'requests a separate token for a different scope set' do
229
+ expect(HTTParty).to receive(:post).twice.and_return(token_response('score-token'), token_response('lineitem-token'))
230
+
231
+ expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('score-token')
232
+ expect(platform.service_access_token(client_id: client_id, scopes: [LtiConstants::AgsScopes::LINEITEM])).to eq('lineitem-token')
233
+ end
234
+
235
+ it 'caches the token for its expires_in less a minute' do
236
+ allow(HTTParty).to receive(:post).and_return(token_response('service-token', expires_in: 600))
237
+ expect(cache).to receive(:write).with(kind_of(String), 'service-token', expires_in: 540.seconds).and_call_original
238
+
239
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
240
+ end
241
+
242
+ it 'does not cache the key under the token or the assertion' do
243
+ allow(HTTParty).to receive(:post).and_return(token_response('service-token'))
244
+ expect(cache).to receive(:write).with(%r{\Apanda_pal/lti_service_tokens/[0-9a-f]{64}\z}, anything, anything)
245
+
246
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
247
+ end
248
+
249
+ it 'fetches a new token when forced' do
250
+ expect(HTTParty).to receive(:post).twice.and_return(token_response('first'), token_response('second'))
251
+
252
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
253
+ expect(platform.service_access_token(client_id: client_id, scopes: [score_scope], force: true)).to eq('second')
254
+ end
255
+
256
+ it 'raises ServiceTokenError with the status, without echoing the response body, on a non-2xx answer' do
257
+ allow(HTTParty).to receive(:post).and_return(http_response(400, '{"error":"invalid_client","assertion":"secret-assertion"}'))
258
+
259
+ expect {
260
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
261
+ }.to raise_error(Platform::ServiceTokenError) { |error|
262
+ expect(error.status).to eq(400)
263
+ expect(error.message).to eq('token request answered 400')
264
+ expect(error).to be_a(PandaPal::LtiServiceError)
265
+ }
266
+ end
267
+
268
+ it 'raises ServiceTokenError when the response has no access_token' do
269
+ allow(HTTParty).to receive(:post).and_return(http_response(200, '{"token_type":"Bearer"}'))
270
+
271
+ expect {
272
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
273
+ }.to raise_error(Platform::ServiceTokenError, /no access_token/)
274
+ end
275
+
276
+ it 'wraps network errors in LtiServiceError' do
277
+ allow(HTTParty).to receive(:post).and_raise(Net::OpenTimeout)
278
+
279
+ expect {
280
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
281
+ }.to raise_error(PandaPal::LtiServiceError, 'token request failed: Net::OpenTimeout')
282
+ end
283
+
284
+ it 'uses the Generic platform grant_url' do
285
+ generic = Platform::Generic.from_urls('https://lms.example.com', grant: '/oauth/token')
286
+ expect(HTTParty).to receive(:post).with('https://lms.example.com/oauth/token', anything).and_return(token_response('generic-token'))
287
+
288
+ expect(generic.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('generic-token')
289
+ end
290
+ end
291
+
292
+ describe '#expire_service_access_token' do
293
+ it 'drops the cached token so the next call requests a new one' do
294
+ expect(HTTParty).to receive(:post).twice.and_return(token_response('first'), token_response('second'))
295
+
296
+ platform.service_access_token(client_id: client_id, scopes: [score_scope])
297
+ platform.expire_service_access_token(client_id: client_id, scopes: [score_scope])
298
+
299
+ expect(platform.service_access_token(client_id: client_id, scopes: [score_scope])).to eq('second')
300
+ end
301
+ end
302
+
303
+ describe '#service_request' do
304
+ let(:url) { 'https://canvas.example.com/api/lti/courses/1/line_items/2/scores' }
305
+
306
+ it 'sends the bearer token with the given headers and body' do
307
+ allow(HTTParty).to receive(:post).with(grant_url, anything).and_return(token_response('service-token'))
308
+ expect(HTTParty).to receive(:post).with(url, hash_including(
309
+ headers: { 'Content-Type' => 'application/json', 'Authorization' => 'Bearer service-token' },
310
+ body: '{}',
311
+ open_timeout: 5,
312
+ )).and_return(http_response(200))
313
+
314
+ response = platform.service_request(:post, url, client_id: client_id, scopes: [score_scope],
315
+ headers: { 'Content-Type' => 'application/json' }, body: '{}')
316
+
317
+ expect(response.code).to eq(200)
318
+ end
319
+
320
+ it 'retries once with a new token when a cached token is refused with a 401' do
321
+ cache.write(platform.send(:service_token_cache_key, client_id, [score_scope]), 'stale-token')
322
+ expect(HTTParty).to receive(:post).with(grant_url, anything).once.and_return(token_response('fresh-token'))
323
+ expect(HTTParty).to receive(:post).with(url, hash_including(headers: { 'Authorization' => 'Bearer stale-token' })).and_return(http_response(401))
324
+ expect(HTTParty).to receive(:post).with(url, hash_including(headers: { 'Authorization' => 'Bearer fresh-token' })).and_return(http_response(200))
325
+
326
+ response = platform.service_request(:post, url, client_id: client_id, scopes: [score_scope])
327
+
328
+ expect(response.code).to eq(200)
329
+ expect(cache.read(platform.send(:service_token_cache_key, client_id, [score_scope]))).to eq('fresh-token')
330
+ end
331
+
332
+ it 'does not retry when a freshly issued token is refused' do
333
+ expect(HTTParty).to receive(:post).with(grant_url, anything).once.and_return(token_response('fresh-token'))
334
+ expect(HTTParty).to receive(:post).with(url, anything).once.and_return(http_response(401))
335
+
336
+ expect(platform.service_request(:post, url, client_id: client_id, scopes: [score_scope]).code).to eq(401)
337
+ end
338
+
339
+ it 'returns the second 401 when the refreshed token is refused too' do
340
+ cache.write(platform.send(:service_token_cache_key, client_id, [score_scope]), 'stale-token')
341
+ allow(HTTParty).to receive(:post).with(grant_url, anything).and_return(token_response('fresh-token'))
342
+ expect(HTTParty).to receive(:post).with(url, anything).twice.and_return(http_response(401))
343
+
344
+ expect(platform.service_request(:post, url, client_id: client_id, scopes: [score_scope]).code).to eq(401)
345
+ end
346
+ end
347
+ end
151
348
  end
152
349
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.17.2
4
+ version: 5.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -133,6 +133,7 @@ executables: []
133
133
  extensions: []
134
134
  extra_rdoc_files: []
135
135
  files:
136
+ - AGS.md
136
137
  - MIT-LICENSE
137
138
  - README.md
138
139
  - Rakefile
@@ -151,6 +152,8 @@ files:
151
152
  - app/lib/lti_xml/base_platform.rb
152
153
  - app/lib/lti_xml/bridge_platform.rb
153
154
  - app/lib/lti_xml/canvas_platform.rb
155
+ - app/lib/panda_pal/ags_client.rb
156
+ - app/lib/panda_pal/ags_helpers.rb
154
157
  - app/lib/panda_pal/deep_linking_helpers.rb
155
158
  - app/lib/panda_pal/launch_url_helpers.rb
156
159
  - app/lib/panda_pal/lti_jwt_validator.rb
@@ -209,6 +212,7 @@ files:
209
212
  - spec/internal/config/storage.yml
210
213
  - spec/internal/db/schema.rb
211
214
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
215
+ - spec/lib/panda_pal/ags_client_spec.rb
212
216
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
213
217
  - spec/lib/panda_pal/helpers/session_replacement_spec.rb
214
218
  - spec/models/panda_pal/api_call_spec.rb
@@ -254,6 +258,7 @@ test_files:
254
258
  - spec/internal/config/storage.yml
255
259
  - spec/internal/db/schema.rb
256
260
  - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
261
+ - spec/lib/panda_pal/ags_client_spec.rb
257
262
  - spec/lib/panda_pal/helpers/secure_headers_spec.rb
258
263
  - spec/lib/panda_pal/helpers/session_replacement_spec.rb
259
264
  - spec/models/panda_pal/api_call_spec.rb