Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/commit-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10']
python-version: ['3.8', '3.9', '3.10', '3.11']

steps:
- uses: actions/checkout@v3
Expand All @@ -26,6 +26,7 @@ jobs:
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.7.1
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
Expand Down
1 change: 1 addition & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ disable=
C0115, # missing-class-docstring
C0116, # missing-function-docstring
W0511, # fixme
R0801, # duplicate-code

extension-pkg-whitelist=pydantic
ignore=framework, migrations
Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,44 @@ class OIDCLogin(OIDCLoginAbstract):
"""
return HttpResponseRedirect(url)

def render_error_page(self, message, status_code):
"""
This will be invoked when the library decides the error must not be
returned as an OAuth redirect.
"""
return HttpResponse(message, status=status_code)

# Initiate login endpoint
def preflight_lti_1p3_launch(request, user_id, *args, **kwargs):
platform = get_registered_platform(*args, **kwargs)
oidc_login = OLOIDCLogin(request, platform)
oidc_login = OIDCLogin(request, platform)

# Redirect the current login user to the tool provider,
return redirect_url.initiate_login(user_id)
return oidc_login.initiate_login(user_id)

```

### OIDC error response behavior

The library decides whether an OIDC/login error should be returned as a redirect
to the tool or rendered locally as an error page.

| Scenario | Error code | Behavior |
|----------|------------|----------|
| Unknown `client_id` | `unauthorized_client` | Redirect to `redirect_uri` with OAuth error params |
| Missing required params | `invalid_request` | Redirect to `redirect_uri` with OAuth error params |
| Wrong `response_type` | `unsupported_response_type` | Redirect to `redirect_uri` with OAuth error params |
| Missing `openid` scope | `invalid_scope` | Redirect to `redirect_uri` with OAuth error params |
| Bad `login_hint` | `invalid_request` | Redirect to `redirect_uri` with OAuth error params |
| Expired `lti_message_hint` | `invalid_request` | Redirect to `redirect_uri` with OAuth error params |
| User not authorized | `access_denied` | Redirect to `redirect_uri` with OAuth error params |
| User not logged in | `login_required` | Redirect to `redirect_uri` with OAuth error params |
| Invalid `redirect_uri` | `invalid_request_uri` | Render local error page |
| Internal signing/config error | `server_error` or `temporarily_unavailable` | Render local error page |

For redirectable errors, the library appends `error`, `error_description`, and
`state` when available. For non-redirectable errors, `render_error_page()` is used.

## LTI Message launch

The tool provider redirect to the platform's OIDC auth request endpoint. The platform received the auth request and it will do some little bit of validation, it needs to ensure user is login, also check the `login_hint` is matched with the `user_id`. The platform also could get the context from the `lti_message_hint` which is sent in the initiating request and do some other validation.
Expand All @@ -91,6 +119,20 @@ class LTI1p3MessageLaunch(MessageLaunchAbstract):
"""
pass

def get_redirect(self, url):
"""
This will be invoked when launch validation fails with a redirectable
OAuth/OIDC error.
"""
return HttpResponseRedirect(url)

def render_error_page(self, message, status_code):
"""
This will be invoked when launch validation fails with a local-only
error such as `invalid_request_uri` or `server_error`.
"""
return HttpResponse(message, status=status_code)

def prepare_launch(self, preflight_response, **kwargs):
"""
You could do some other checks and get some contexts from `lti_message_hint` you've set in previous request
Expand All @@ -106,11 +148,18 @@ class LTI1p3MessageLaunch(MessageLaunchAbstract):

def lti_resource_link_launch(request, *args, **kwargs):
platform = get_registered_platform(*args, **kwargs)
message_launch = LTI1p3MessageLaunch(request, *args, **kwargs)
message_launch = LTI1p3MessageLaunch(request, platform)

return launch.lti_launch(*args, **kwargs)
return message_launch.lti_launch(*args, **kwargs)
```

### Launch error response behavior

`lti_launch()` uses the same policy as OIDC login:

- Redirect to the supplied `redirect_uri` for redirectable OAuth/OIDC errors such as `invalid_request`, `unauthorized_client`, `unsupported_response_type`, `invalid_scope`, `access_denied`, and `login_required`.
- Render a local error page for non-redirectable or server-side failures such as `invalid_request_uri`, `server_error`, and `temporarily_unavailable`.

## Examples

[Django example](examples/django_platform/README.md)
Expand Down
10 changes: 8 additions & 2 deletions examples/django_platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@ You also need to add platform config to the tool config file [game.json](https:/
"client_id": "12345",
"auth_login_url": "http://127.0.0.1:9002/authorization",
"auth_token_url": "http://127.0.0.1:9002/access_token",
"auth_audience": null,
"key_set_url": "http://127.0.0.1:9002/jwks",
"auth_audience": "http://127.0.0.1:9002/access_token",
"key_set_url": "https://127.0.0.1:9002/jwks",
"key_set": null,
"private_key_file": "private.key",
"public_key_file": "public.key",
"deployment_ids": ["1"]
}]
}

Security note for recent validation updates:

- `auth_audience` must match the platform access token endpoint.
- Tool key set URLs must be HTTPS (`https://`) to pass JWKS URL validation.
- If you run locally without TLS, use a local HTTPS proxy/tunnel for the JWKS endpoint.

Now there is game example tool you can launch into on the port 9001 which is already set up in `platform.json`:

Initial Login URL: http://127.0.0.1:9001/login
Expand Down
50 changes: 50 additions & 0 deletions examples/django_platform/django_platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,51 @@


class LTIPlatformConf(LTI1P3PlatformConfAbstract):
"""
Concrete platform configuration for the example Django app.

Cache backend
-------------
Replay-detection (JTI and nonce) is backed by Django's cache framework
(``django.core.cache.cache``) so that entries are shared across
processes and survive restarts. Configure the backend in settings.py;
the default LocMemCache works for local development.

For production, point ``CACHES`` at Redis or Memcached::

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
"""

# ------------------------------------------------------------------ #
# Replay-detection cache (implements LTI1P3PlatformConfAbstract API) #
# ------------------------------------------------------------------ #

def cache_get(self, key: str) -> t.Optional[int]:
"""
Look up a replay-detection entry via Django's cache framework.
Returns the stored expiration timestamp, or None if absent/expired.
"""
from django.core.cache import cache # pylint: disable=import-outside-toplevel

return cache.get(key) # type: ignore[return-value]

def cache_set(self, key: str, exp: int) -> None:
"""
Persist a replay-detection entry via Django's cache framework.
TTL is derived from the token expiration so entries are evicted
automatically when the token would already be invalid.
"""
import time as _time # pylint: disable=import-outside-toplevel
from django.core.cache import cache # pylint: disable=import-outside-toplevel

ttl = max(1, exp - int(_time.time()))
cache.set(key, exp, timeout=ttl)

def init_platform_config(self, platform_settings: t.Dict[str, t.Any]) -> None:
"""
register platform configuration
Expand All @@ -42,6 +87,11 @@ def init_platform_config(self, platform_settings: t.Dict[str, t.Any]) -> None:
.set_platform_private_key(platform_settings["private_key"])
)

access_token_url = platform_settings.get("access_token_url") or get_url(
reverse("access-token")
)
registration.set_access_token_url(access_token_url)

self._registration = registration

def get_registration_by_params(self, **kwargs: t.Any) -> Registration:
Expand Down
159 changes: 145 additions & 14 deletions lti1p3platform/ags.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
"""
LTI Advantage Assignments and Grades service implementation
LTI 1.3 Advantage Services - Assignments and Grades Service (AGS) Implementation

Assignments and Grades Service (AGS):
====================================
AGS allows tools (like homework/quiz platforms) to:
1. Create grading items (assignments/assessments) in the LMS
2. Submit student grades/results back to the LMS
3. Query existing grades and assignments

Real-World Example:
- Student uses external quiz tool to take quiz
- Quiz tool submits grade back to platform
- Platform records grade in gradebook
- Instructor can see student's quiz grade in LMS gradebook
- Tool can integrate with platform's grading system

Security:
- Tool must request specific OAuth scopes for AGS
- Platform validates scopes before allowing API calls
- All API calls use access_token (JWT Bearer token)
- Scopes control what tool can do:
* lineitem.readonly: See assignments only
* score: Submit new grades (recommended for quiz tools)
* result.readonly: See grades only
* lineitem: Create/delete assignments (for creation tools)
* result: Modify grades (broader than score)

Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/
"""
from __future__ import annotations

Expand All @@ -8,16 +35,47 @@

class LtiAgs:
"""
LTI Advantage Consumer

Implements LTI Advantage Services and ties them in
with the LTI Consumer. This only handles the LTI
message claim inclusion and token handling.

Available services:
* Assignments and Grades services claim

Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/#assignment-and-grade-service-claim
LTI 1.3 Advantage Services - Assignments and Grades Service Configuration

AGS provides three main APIs:

1. LineItem API (Assignment Management API):
- GET /lineitems: List all grading items (assignments)
- POST /lineitems: Create new grading item (if allowed)
- GET /lineitems/{id}: Get specific grading item details
- PUT /lineitems/{id}: Update grading item
- DELETE /lineitems/{id}: Delete grading item

Scopes required:
- lineitem.readonly: View only
- lineitem: Create/modify/delete

2. Score API (Grade Submission API):
- POST /lineitems/{id}/scores: Submit student grade
- Scopes: score (most restrictive, recommended)
- Allows tool to submit grades without modifying items

3. Result API (Detailed Grade Query API):
- GET /lineitems/{id}/results: Retrieve all results for an item
- GET /lineitems/{id}/results/{user_id}: Get specific student's result
- Scopes: result.readonly (view) or result (modify)

This class configures which AGS capabilities are available to tools.

Parameters:
- lineitems_url: Platform's API endpoint for listing/creating assignments
- lineitem_url: Template URL for accessing specific assignment (contains {id})
- allow_creating_lineitems: If False, tool can only see existing items (not create)
- results_service_enabled: If True, tool can query student results
- scores_service_enabled: If True, tool can submit grades

Platform Security Considerations:
- Only enable services actually used by this tool
- Restrict scopes to minimum needed
- Monitor tool's API usage for suspicious patterns
- Default: conservative (results=true, scores=true, creation=false)

Reference: https://www.imsglobal.org/spec/lti-ags/v2p0/
"""

# pylint: disable=too-many-arguments
Expand All @@ -30,23 +88,96 @@ def __init__(
scores_service_enabled: bool = True,
) -> None:
"""
Instance class with LTI AGS Global settings.
Initialize AGS configuration for a tool integration

Parameters:
lineitems_url: Platform's API endpoint for line item list/creation
- Format: "https://platform.edu/lti/ags/lineitems"
- Tool makes GET/POST requests to this endpoint
- Required if scores/results services enabled

lineitem_url: Template URL for accessing individual line item
- Format: "https://platform.edu/lti/ags/lineitems/123"
- Contains {id} placeholder replaced with item ID
- Required if scores/results services enabled

allow_creating_lineitems: Allow tool to create new assignments
- Default: False (tool can only use existing items)
- Set True for content creation tools
- False prevents tool from cluttering gradebook

results_service_enabled: Allow tool to query student results
- Default: True (most tools need this)
- Disabled if tool only submits grades (no results lookup)
- Results API requires 'result.readonly' or 'result' scope

scores_service_enabled: Allow tool to submit grades/scores
- Default: True (most tools need this)
- Disabled if tool is view-only
- Scores API requires 'score' scope
"""
# If the platform allows creating lineitems, set this
# to True.
# to True. This allows tools like content creators to add
# new assignments to the platform's gradebook.
self.allow_creating_lineitems = allow_creating_lineitems

# Result and scores services
# These indicate which AGS APIs the platform supports
self.results_service_enabled = results_service_enabled
self.scores_service_enabled = scores_service_enabled

# Lineitems urls
# These are the API endpoints where tool makes requests
self.lineitems_url = lineitems_url
self.lineitem_url = lineitem_url

def get_available_scopes(self) -> t.List[str]:
"""
Retrieves list of available token scopes in this instance.
Retrieves list of available OAuth 2.0 scopes for this AGS configuration

OAuth 2.0 Scopes determine what the tool is allowed to do on the platform.
Scopes are included in the access_token JWT and validated by the platform.

Available AGS Scopes:
- https://purl.imsglobal.org/spec/lti-ags/scope/lineitem
* Create/modify/delete line items (assignments)
* Requires: allow_creating_lineitems=True
* Only included if tool needs to create items

- https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly
* View line items only, cannot modify
* Less restrictive than lineitem
* Default for tools that don't create items

- https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly
* View student results/grades only
* Cannot modify or change grades
* Safest scope for read-only tools

- https://purl.imsglobal.org/spec/lti-ags/scope/result
* View and modify student results/grades
* More permissive than result.readonly
* Used by tools that need full result access

- https://purl.imsglobal.org/spec/lti-ags/scope/score
* Submit grades for students
* Most restrictive scope (RECOMMENDED!)
* Used by quiz/homework tools
* Cannot view other students' scores

Scope Selection Best Practice:
- Use 'score' if only submitting grades (quiz tools, most secure)
- Use 'lineitem.readonly' if only viewing assignments
- Use 'result.readonly' if only viewing grades
- Use 'result' only if truly needing result modification
- Use 'lineitem' only for content creation tools

Returns:
List of scope URIs the platform will provide tokens for

Reference:
- Scope descriptions: https://www.imsglobal.org/spec/lti-ags/v2p0/#scopes
- OAuth 2.0 Scopes: https://tools.ietf.org/html/rfc6749#section-3.3
"""
scopes = []

Expand Down
Loading
Loading