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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a84b75147fd0b4ac07c4f0aafdda286b9e1f7a5cb16d64b8139e94b12ff82707
|
|
4
|
+
data.tar.gz: ff14468ea46d91e4aca58048366e9bd93a481ec985ccb87d29ca270d70bd2f18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
|
@@ -148,10 +148,7 @@ module Apartment
|
|
|
148
148
|
|
|
149
149
|
raise ActiveRecord::StatementInvalid, "PandaPal/Apartment Mutli-DB support does not currently support DB roles" if ActiveRecord::Base.current_role != ActiveRecord::Base.default_role
|
|
150
150
|
|
|
151
|
-
unless
|
|
152
|
-
Apartment.establish_connection multi_tenantify(tenant, false)
|
|
153
|
-
Apartment.connection.verify!
|
|
154
|
-
end
|
|
151
|
+
establish_connection_pool(tenant) unless connection_pool_registered?
|
|
155
152
|
|
|
156
153
|
Apartment.connection.enable_query_cache! if query_cache_enabled
|
|
157
154
|
|
|
@@ -171,6 +168,25 @@ module Apartment
|
|
|
171
168
|
|
|
172
169
|
protected
|
|
173
170
|
|
|
171
|
+
ESTABLISH_CONNECTION_LOCK = Mutex.new
|
|
172
|
+
|
|
173
|
+
def connection_pool_registered?
|
|
174
|
+
!ActiveRecord::Base.connection_handler.retrieve_connection_pool(
|
|
175
|
+
ActiveRecord::Base.connection_specification_name,
|
|
176
|
+
role: ActiveRecord::Base.current_role,
|
|
177
|
+
shard: ActiveRecord::Base.current_shard,
|
|
178
|
+
).nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def establish_connection_pool(tenant)
|
|
182
|
+
ESTABLISH_CONNECTION_LOCK.synchronize do
|
|
183
|
+
next if connection_pool_registered?
|
|
184
|
+
|
|
185
|
+
Apartment.establish_connection multi_tenantify(tenant, false)
|
|
186
|
+
Apartment.connection.verify!
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
174
190
|
def persistent_schemas
|
|
175
191
|
map_to_schemas(super)
|
|
176
192
|
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
|
data/lib/panda_pal/version.rb
CHANGED