From 5cc280149e837e0c5175dbced14174232ceb1482 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 3 Aug 2026 16:45:17 -0700 Subject: [PATCH 1/4] Use SERVER_PROTOCOL to identify when to set secure cookies --- components/rsptx/auth/session.py | 2 +- components/rsptx/configuration/core.py | 15 +++++++++++++++ .../lti1p3/pylti1p3/contrib/fastapi/request.py | 5 ++++- sample.env | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/rsptx/auth/session.py b/components/rsptx/auth/session.py index 58b92cee1..ffa9a9097 100644 --- a/components/rsptx/auth/session.py +++ b/components/rsptx/auth/session.py @@ -64,7 +64,7 @@ def cookie_domain(self) -> Optional[str]: return settings.load_balancer_host or None def set_cookie(self, response, token): - production = settings.server_config == "production" + production = settings.server_protocol.startswith("https://") domain = self.cookie_domain if domain: # Anyone who logged in before the cookie was scoped still has a diff --git a/components/rsptx/configuration/core.py b/components/rsptx/configuration/core.py index 274ecafc6..51892cc79 100644 --- a/components/rsptx/configuration/core.py +++ b/components/rsptx/configuration/core.py @@ -181,6 +181,21 @@ def server_url(self) -> str: scheme = "https" if self.certbot_email or self.caddy_site_address else "http" return f"{scheme}://{self.runestone_host}" + @property + def server_protocol(self) -> str: + """Return the scheme (protocol) for this deployment. + + Similar to above but only stores the scheme. + + :return: The scheme, e.g. ``https://``. + :rtype: str + """ + if self.load_balancer_host: + return "https://" + if self.caddy_site_address: + return "https://" + return "https://" if self.certbot_email else "http://" + # Configure ads. TODO: Link to the place in the Runestone Components where this is used. adsenseid: str = "" num_banners: int = 0 diff --git a/components/rsptx/lti1p3/pylti1p3/contrib/fastapi/request.py b/components/rsptx/lti1p3/pylti1p3/contrib/fastapi/request.py index fc73bc82d..146feb6c1 100644 --- a/components/rsptx/lti1p3/pylti1p3/contrib/fastapi/request.py +++ b/components/rsptx/lti1p3/pylti1p3/contrib/fastapi/request.py @@ -64,7 +64,10 @@ def __init__( self._cookies = request_obj.cookies if cookies is None else cookies self._session = request_obj.session if session is None else session - is_https = request_obj.url.scheme.lower() == "https" + is_https = ( + request_obj.url.scheme.lower() == "https" + or "https" in request_obj.headers.get("x-forwarded-proto", "").lower() + ) self._request_is_secure = ( is_https if request_is_secure is None else request_is_secure ) diff --git a/sample.env b/sample.env index 78d423962..c57d90c63 100644 --- a/sample.env +++ b/sample.env @@ -87,7 +87,7 @@ MAKE YOUR OWN KEY PAIR AND PASTE PUBLIC KEY HERE # that is not localhost. ALLOW_INSECURE_LOGIN = False -# this is used by web2py to decide on how to set the session cookie settings. +# this is used to decide on how to set the session cookie settings. # In production you will want to change this to https:// SERVER_PROTOCOL=http:// From 938e2a49fd8559e4500a059d7c846fdf0d9c6a1f Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 3 Aug 2026 16:45:37 -0700 Subject: [PATCH 2/4] Sync LTI availability dates to assignments --- .../rsptx/admin_server_api/routers/lti1p3.py | 69 ++++++++++++++++--- .../rsptx/lti1p3/pylti1p3/message_launch.py | 60 +++++++++++++++- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index aa679ce66..cb99b38c9 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -107,7 +107,6 @@ from rsptx.lti1p3.pylti1p3.exception import LtiException, LtiServiceException from rsptx.lti1p3.pylti1p3.deep_link import DeepLinkResource - # Routing # ======= router = APIRouter( @@ -147,6 +146,27 @@ def get_session_service(): return FastAPISessionService(RedisCache()) +def parse_lti_datetime_as_utc( + datetime_string: Optional[str], +) -> Optional[datetime.datetime]: + """ + Parse an LTI ISO datetime and return a naive UTC datetime for storage. + Unresolved LTI substitution values indicate that the LMS has no value set. + """ + if not datetime_string or datetime_string.startswith("$ResourceLink.available."): + return None + + normalized_datetime_string = ( + datetime_string.replace("Z", "+00:00") + if datetime_string.endswith("Z") + else datetime_string + ) + lti_datetime = datetime.datetime.fromisoformat(normalized_datetime_string) + if lti_datetime.tzinfo is None: + return lti_datetime + return lti_datetime.astimezone(datetime.timezone.utc).replace(tzinfo=None) + + async def login_or_create_user( launch: FastAPIMessageLaunch, lti_course: Lti1p3Course, course: CoursesValidator ) -> tuple[Lti1p3User, str]: @@ -353,8 +373,6 @@ async def launch(request: Request): key, value = p.split("=") query_params[key] = value - rslogger.debug(f"LTI1p3 - launch params: {query_params}") - # Start by identifying the kind of launch this is. Will need different info from different kinds of launches # Type 1: Book links - links to specific pages in the book # They will have a query param "book_page" @@ -471,8 +489,9 @@ async def launch(request: Request): # make sure RS assignment is up to date (e.g. end date) course_attributes = await fetch_all_course_attributes(course.id) + custom_params = message_launch.get_custom_params() await update_rsassignment_from_lti( - rs_assign, assign_lineitem, course_attributes, course + rs_assign, assign_lineitem, course_attributes, course, custom_params ) # start redirect to assignment @@ -523,10 +542,16 @@ async def update_rsassignment_from_lti( line_item: LineItem, course_attributes: dict, course: Courses, + custom_params: Optional[dict] = None, ) -> AssignmentValidator: """ Update a runestone assignment from LTI data. """ + if course_attributes.get("ignore_lti_dates") == "true": + return assign + + updated = False + try: lms_due_string = line_item.get_end_date_time() rslogger.info( @@ -551,16 +576,37 @@ async def update_rsassignment_from_lti( lms_due = lms_due.replace(tzinfo=tz) lms_due = lms_due.astimezone(datetime.timezone.utc).replace(tzinfo=None) rslogger.info(f"LTI1p3 - Storing {lms_due} UTC for assignment {assign.name}") - if ( - lms_due is not None - and lms_due != assign.duedate - and course_attributes.get("ignore_lti_dates") != "true" - ): + if lms_due is not None and lms_due != assign.duedate: assign.duedate = lms_due - await update_assignment(assign) + updated = True except Exception: # just ignore bad dates, could be missing, bad format, etc pass + + custom_params = custom_params or {} + availability_datetime_params = ( + ("visible_on", "resource_link_available_startdatetime"), + ("hidden_on", "resource_link_available_enddatetime"), + ) + for assignment_field, custom_param in availability_datetime_params: + if custom_param not in custom_params: + continue + + try: + availability_datetime = parse_lti_datetime_as_utc( + custom_params.get(custom_param) + ) + except Exception: + # just ignore bad dates, could be missing, bad format, etc + continue + + if availability_datetime != getattr(assign, assignment_field): + setattr(assign, assignment_field, availability_datetime) + updated = True + + if updated: + await update_assignment(assign) + return assign @@ -682,6 +728,9 @@ async def register_with_platform(platform_config: dict, token: str = None) -> di "custom_parameters": { "context_id_history": "$Context.id.history", "resource_link_history": "$ResourceLink.id.history", + "resource_link_submission_enddatetime": "$ResourceLink.submission.endDateTime", + "resource_link_available_startdatetime": "$ResourceLink.available.startDateTime", + "resource_link_available_enddatetime": "$ResourceLink.available.endDateTime", }, "claims": [ "sub", diff --git a/components/rsptx/lti1p3/pylti1p3/message_launch.py b/components/rsptx/lti1p3/pylti1p3/message_launch.py index 6ba72095a..8fde9adea 100644 --- a/components/rsptx/lti1p3/pylti1p3/message_launch.py +++ b/components/rsptx/lti1p3/pylti1p3/message_launch.py @@ -38,7 +38,6 @@ from .service_connector import ServiceConnector, REQUESTS_USER_AGENT from .tool_config import ToolConfAbstract - TResourceLinkClaim = te.TypedDict( "TResourceLinkClaim", { @@ -177,6 +176,9 @@ total=False, ) + +CUSTOM_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/custom" + REQ = t.TypeVar("REQ", bound=Request) TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract) SES = t.TypeVar("SES", bound=SessionService) @@ -462,6 +464,62 @@ def has_ags(self) -> bool: is not None ) + def has_custom_params(self) -> bool: + """ + Returns whether or not the current launch contains LTI custom parameters. + + :return: bool + """ + custom_params = self._get_jwt_body().get(CUSTOM_CLAIM, None) + return isinstance(custom_params, dict) and len(custom_params) > 0 + + def get_custom_params(self) -> t.Mapping[str, str]: + """ + Fetch custom parameters from the launch payload. + + :return: Mapping[str, str] + """ + custom_params = self._get_jwt_body().get(CUSTOM_CLAIM, {}) + if custom_params is None: + return {} + if not isinstance(custom_params, dict): + raise LtiException("custom claim must be an object") + return t.cast(t.Mapping[str, str], custom_params) + + def has_custom_param(self, key: str) -> bool: + """ + Returns whether a named custom parameter exists in the launch payload. + + :param key: Custom parameter key. + :return: bool + """ + return key in self.get_custom_params() + + def get_custom_param( + self, key: str, default_value: t.Optional[str] = None + ) -> t.Optional[str]: + """ + Fetch a single custom parameter from the launch payload. + + :param key: Custom parameter key. + :param default_value: Value to return when key is missing. + :return: str | None + """ + return self.get_custom_params().get(key, default_value) + + def require_custom_param(self, key: str) -> str: + """ + Fetch a required custom parameter and fail if it is missing. + + :param key: Custom parameter key. + :return: str + :raises LtiException: If key is missing from custom params. + """ + value = self.get_custom_param(key) + if value is None: + raise LtiException(f"Missing custom launch param '{key}'") + return value + def get_dls(self) -> TDeepLinkData: """ Fetches deep linking settings for the current launch. From db739b170dac5b01c5a2a1ae936f336609c76df4 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 3 Aug 2026 16:45:50 -0700 Subject: [PATCH 3/4] Assignment builder: prefer scheduled_period to scheduled_hidden when start and end set --- .../components/edit/visibilityMode.spec.ts | 4 ++-- .../AssignmentBuilder/components/edit/visibilityMode.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.spec.ts index d40351567..43ccbd988 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.spec.ts @@ -27,9 +27,9 @@ describe("getVisibilityMode", () => { expect(getVisibilityMode(undefined, null, null)).toBe("hidden"); }); - it("prioritizes scheduled_hidden over plain visible when both visible and hidden_on set", () => { + it("prefers scheduled_period when both visible_on and hidden_on are set, even if visible", () => { expect(getVisibilityMode(true, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")).toBe( - "scheduled_hidden" + "scheduled_period" ); }); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.ts index 36d8edfb2..18f1e2325 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityMode.ts @@ -10,10 +10,10 @@ export const getVisibilityMode = ( visibleOn: string | null | undefined, hiddenOn: string | null | undefined ): VisibilityMode => { + if (visibleOn && hiddenOn) { + return "scheduled_period"; + } if (!visible) { - if (visibleOn && hiddenOn) { - return "scheduled_period"; - } if (visibleOn) { return "scheduled_visible"; } From 7186d09620bc92a73ce299c76b23344d1905c882 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 3 Aug 2026 17:30:43 -0700 Subject: [PATCH 4/4] LTI1p3: sync RS availability range to LMS on initial import --- .../rsptx/admin_server_api/routers/lti1p3.py | 74 ++++++++++++++++--- .../lti1p3/pylti1p3/deep_link_resource.py | 27 ++++++- .../rsptx/lti1p3/test_duedate_exchange.py | 64 +++++++++++++++- 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index cb99b38c9..08a2a0520 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -151,9 +151,10 @@ def parse_lti_datetime_as_utc( ) -> Optional[datetime.datetime]: """ Parse an LTI ISO datetime and return a naive UTC datetime for storage. - Unresolved LTI substitution values indicate that the LMS has no value set. + Unresolved LTI substitution values indicate that the LMS has a null (but knows about the variable). + An unresolved parameter string indicates that the LMS does not recognize the variable. That will become an exception. """ - if not datetime_string or datetime_string.startswith("$ResourceLink.available."): + if not datetime_string or datetime_string == "": return None normalized_datetime_string = ( @@ -167,6 +168,22 @@ def parse_lti_datetime_as_utc( return lti_datetime.astimezone(datetime.timezone.utc).replace(tzinfo=None) +def format_lti_datetime_as_utc( + datetime_value: Optional[datetime.datetime], +) -> Optional[str]: + """ + Format a naive UTC datetime for LTI as an explicit UTC ISO datetime. + """ + if datetime_value is None: + return None + + if datetime_value.tzinfo is None: + datetime_value = datetime_value.replace(tzinfo=datetime.timezone.utc) + else: + datetime_value = datetime_value.astimezone(datetime.timezone.utc) + return datetime_value.isoformat().replace("+00:00", "Z") + + async def login_or_create_user( launch: FastAPIMessageLaunch, lti_course: Lti1p3Course, course: CoursesValidator ) -> tuple[Lti1p3User, str]: @@ -488,10 +505,18 @@ async def launch(request: Request): await update_lti_assignment_record(assign_lineitem, lti_course, rs_assign) # make sure RS assignment is up to date (e.g. end date) + lti_config = await fetch_lti1p3_config_by_lti_data( + message_launch.get_iss(), message_launch.get_client_id() + ) course_attributes = await fetch_all_course_attributes(course.id) custom_params = message_launch.get_custom_params() await update_rsassignment_from_lti( - rs_assign, assign_lineitem, course_attributes, course, custom_params + rs_assign, + assign_lineitem, + course_attributes, + course, + custom_params, + lti_config.product_family_code, ) # start redirect to assignment @@ -543,6 +568,7 @@ async def update_rsassignment_from_lti( course_attributes: dict, course: Courses, custom_params: Optional[dict] = None, + product_family_code: Optional[str] = None, ) -> AssignmentValidator: """ Update a runestone assignment from LTI data. @@ -589,21 +615,44 @@ async def update_rsassignment_from_lti( ("hidden_on", "resource_link_available_enddatetime"), ) for assignment_field, custom_param in availability_datetime_params: - if custom_param not in custom_params: + raw_value = custom_params.get(custom_param) + if raw_value is None: continue + # If the LMS does not recognize the variable, it will be returned verbatim. + # Otherwise, it should be a valid ISO datetime string or empty string. + # Empty indicates a meaningful null value. try: - availability_datetime = parse_lti_datetime_as_utc( - custom_params.get(custom_param) - ) + availability_datetime = parse_lti_datetime_as_utc(raw_value) except Exception: - # just ignore bad dates, could be missing, bad format, etc - continue + # Bad datetime or the verbatim param string. + # For most LMS's that means we should ignore. But Canvas does know + # about these variables and returns an unresolved variable + # when there is a null value. + if ( + product_family_code == "canvas" + and isinstance(raw_value, str) + and raw_value.startswith("$ResourceLink.available.") + ): + availability_datetime = None + else: + # just ignore bad dates, could be missing, bad format, etc + continue if availability_datetime != getattr(assign, assignment_field): setattr(assign, assignment_field, availability_datetime) updated = True + # check for a custom parameter that indicates the assignment is published in Canvas + canvas_assignment_published = custom_params.get("canvas_assignment_published") + if ( + canvas_assignment_published is not None + and str(canvas_assignment_published).lower() == "true" + ): + if not assign.visible: + assign.visible = True + updated = True + if updated: await update_assignment(assign) @@ -731,6 +780,7 @@ async def register_with_platform(platform_config: dict, token: str = None) -> di "resource_link_submission_enddatetime": "$ResourceLink.submission.endDateTime", "resource_link_available_startdatetime": "$ResourceLink.available.startDateTime", "resource_link_available_enddatetime": "$ResourceLink.available.endDateTime", + "canvas_assignment_published": "$Canvas.assignment.published", }, "claims": [ "sub", @@ -1183,6 +1233,12 @@ async def assign_select(launch_id: str, request: Request, course=None): dlr.set_url(launch_url) dlr.set_title(assign.name) dlr.set_target("window") + available_start_datetime = format_lti_datetime_as_utc(assign.visible_on) + if available_start_datetime: + dlr.set_available_start_date_time(available_start_datetime) + available_end_datetime = format_lti_datetime_as_utc(assign.hidden_on) + if available_end_datetime: + dlr.set_available_end_date_time(available_end_datetime) line_item = LineItem() update_line_item_from_assignment( diff --git a/components/rsptx/lti1p3/pylti1p3/deep_link_resource.py b/components/rsptx/lti1p3/pylti1p3/deep_link_resource.py index 22181a17c..2c49357bc 100644 --- a/components/rsptx/lti1p3/pylti1p3/deep_link_resource.py +++ b/components/rsptx/lti1p3/pylti1p3/deep_link_resource.py @@ -10,6 +10,8 @@ class DeepLinkResource: _custom_params: t.Mapping[str, str] = None _target: str = "window" _icon_url: t.Optional[str] = None + _available_start_date_time: t.Optional[str] = None + _available_end_date_time: t.Optional[str] = None def get_type(self): return self._type @@ -53,6 +55,20 @@ def set_target(self, value: str) -> "DeepLinkResource": self._target = value return self + def get_available_start_date_time(self) -> t.Optional[str]: + return self._available_start_date_time + + def set_available_start_date_time(self, value: str) -> "DeepLinkResource": + self._available_start_date_time = value + return self + + def get_available_end_date_time(self) -> t.Optional[str]: + return self._available_end_date_time + + def set_available_end_date_time(self, value: str) -> "DeepLinkResource": + self._available_end_date_time = value + return self + def get_icon_url(self) -> t.Optional[str]: return self._icon_url @@ -74,6 +90,14 @@ def to_dict(self) -> t.Dict[str, object]: if self._target == "window": res["window"] = {"targetName": "_runestone"} + available: t.Dict[str, object] = {} + if self._available_start_date_time: + available["startDateTime"] = self._available_start_date_time + if self._available_end_date_time: + available["endDateTime"] = self._available_end_date_time + if available: + res["available"] = available + if self._lineitem: line_item: t.Dict[str, object] = { "scoreMaximum": self._lineitem.get_score_maximum(), @@ -95,11 +119,8 @@ def to_dict(self) -> t.Dict[str, object]: if submission_review: line_item["submissionReview"] = submission_review - # if line item has a end date, include it in the resource - # as both availability and submission end dates end_date_time = self._lineitem.get_end_date_time() if end_date_time: - res["available"] = {"endDateTime": end_date_time} res["submission"] = {"endDateTime": end_date_time} res["lineItem"] = line_item diff --git a/test/components/rsptx/lti1p3/test_duedate_exchange.py b/test/components/rsptx/lti1p3/test_duedate_exchange.py index d5de6ec1e..55b7f3a50 100644 --- a/test/components/rsptx/lti1p3/test_duedate_exchange.py +++ b/test/components/rsptx/lti1p3/test_duedate_exchange.py @@ -21,7 +21,14 @@ def _course(timezone="America/Chicago", id=1): def _assignment(duedate=None, id=7): - return SimpleNamespace(id=id, name="Homework 1", duedate=duedate, points=10) + return SimpleNamespace( + id=id, + name="Homework 1", + duedate=duedate, + visible_on=None, + hidden_on=None, + points=10, + ) # Ingest @@ -100,6 +107,61 @@ async def test_ingest_ignores_an_unparseable_date(): assert assign.duedate == original +async def test_ingest_clears_canvas_unresolved_available_date(): + from rsptx.admin_server_api.routers import lti1p3 + + original = datetime.datetime(2026, 1, 1, 12, 0) + assign = _assignment( + duedate=original, + ) + assign.visible_on = datetime.datetime(2026, 1, 2, 9, 0) + line_item = LineItem() + line_item.set_end_date_time("2026-09-01T23:59:00-05:00") + custom_params = { + "resource_link_available_startdatetime": "$ResourceLink.available.startDateTime" + } + + with patch.object(lti1p3, "update_assignment", AsyncMock()) as update: + await lti1p3.update_rsassignment_from_lti( + assign, + line_item, + {}, + _course(), + custom_params, + "canvas", + ) + + assert update.await_count == 1 + assert assign.visible_on is None + assert assign.duedate == datetime.datetime(2026, 9, 2, 4, 59) + + +async def test_ingest_ignores_unresolved_available_date_for_non_canvas(): + from rsptx.admin_server_api.routers import lti1p3 + + original = datetime.datetime(2026, 1, 1, 12, 0) + assign = _assignment(duedate=original) + assign.visible_on = datetime.datetime(2026, 1, 2, 9, 0) + line_item = LineItem() + line_item.set_end_date_time("2026-09-01T23:59:00-05:00") + custom_params = { + "resource_link_available_startdatetime": "$ResourceLink.available.startDateTime" + } + + with patch.object(lti1p3, "update_assignment", AsyncMock()) as update: + await lti1p3.update_rsassignment_from_lti( + assign, + line_item, + {}, + _course(), + custom_params, + "moodle", + ) + + update.assert_awaited() + assert assign.visible_on == datetime.datetime(2026, 1, 2, 9, 0) + + # Push # ----