panda_pal 5.17.1 → 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.
@@ -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.1
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