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.
- checksums.yaml +4 -4
- data/AGS.md +197 -0
- data/README.md +23 -0
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +1 -1
- data/app/lib/panda_pal/ags_client.rb +197 -0
- data/app/lib/panda_pal/ags_helpers.rb +7 -0
- data/app/models/panda_pal/platform.rb +104 -0
- data/app/models/panda_pal/session.rb +4 -0
- data/config/initializers/apartment.rb +20 -4
- data/lib/panda_pal/lti_constants.rb +19 -0
- data/lib/panda_pal/version.rb +1 -1
- data/lib/panda_pal.rb +18 -0
- data/panda_pal.gemspec +1 -1
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +30 -0
- data/spec/core/apartment_multidb_spec.rb +66 -0
- data/spec/lib/panda_pal/ags_client_spec.rb +385 -0
- data/spec/models/panda_pal/platform_spec.rb +197 -0
- metadata +6 -1
|
@@ -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.
|
|
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
|