diff --git a/.codex/skills/software-engineer/SKILL.md b/.codex/skills/software-engineer/SKILL.md new file mode 100644 index 0000000..b4f6f6c --- /dev/null +++ b/.codex/skills/software-engineer/SKILL.md @@ -0,0 +1,62 @@ +--- +name: software-engineer +description: Implement and investigate software features and bugfixes for a senior full-stack engineering workflow with Gleam, Erlang/OTP, web technologies, and LTI/LMS integrations. Use when asked to debug, investigate, implement, or ship product changes; for larger features, drive execution from docs/features//prd.md, fdd.md, and plan.md. +--- + +# Software Engineer + +## Overview + +Execute pragmatic, production-quality feature and bugfix work across backend, frontend, and integration boundaries. + +## Core Workflow + +1. Gather context. + +- Read the prompt, related code paths, tests, and recent diffs. +- Identify affected domains: Gleam/Erlang service logic, web UI/API contracts, and LTI/LMS integration points. + +2. Classify the work size. + +- Use `investigation` for root-cause analysis or uncertain requirements. +- Use `small-change` for isolated bugfixes or narrowly scoped feature updates. +- Use `feature-delivery` for larger functionality that should follow a feature spec. + +3. Select execution path. + +- `investigation`: Reproduce, isolate, form hypothesis, validate with evidence, propose minimal fix. +- `small-change`: Implement smallest safe patch, preserve behavior outside scope, add/update tests. +- `feature-delivery`: Read spec files in order: + 1. `docs/features//prd.md` + 2. `docs/features//fdd.md` + 3. `docs/features//plan.md` + Treat `prd.md` as product source of truth, `fdd.md` as design constraints, `plan.md` as execution sequence. + +4. Implement with stack discipline. + +- Keep OTP boundaries explicit: processes, supervision, message flow, failure handling, and timeouts. +- Keep Gleam types and module interfaces clear; prefer small, composable functions. +- Preserve backward compatibility for web contracts unless the spec explicitly changes them. +- For LMS/LTI work, enforce correctness for launch/auth flows, claims, roles, and assignment/grade data handling. + +5. Verify before handoff. + +- Run the narrowest relevant tests first, then broader suites as needed. +- Validate error paths, edge cases, and integration assumptions. +- Confirm docs/config/migrations are updated when behavior changes. + +6. Report clearly. + +- State what changed, why, and what was validated. +- List assumptions, risks, and follow-up tasks if scope was constrained. + +## Delivery Rules + +- Prefer small, reviewable commits and minimal blast radius. +- Avoid speculative refactors unless required to complete the task safely. +- Preserve existing design-system and architecture conventions. +- Escalate contradictions between spec files; do not silently guess. + +## References + +- Use [references/execution-checklists.md](references/execution-checklists.md) for repeatable checklists. diff --git a/.codex/skills/software-engineer/agents/openai.yaml b/.codex/skills/software-engineer/agents/openai.yaml new file mode 100644 index 0000000..2443bbb --- /dev/null +++ b/.codex/skills/software-engineer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Software Engineer" + short_description: "Ship features and bugfixes across Gleam, OTP, web, and LTI" + default_prompt: "Use $software-engineer to investigate or implement a bugfix/feature, and for larger work follow docs/features//prd.md, fdd.md, and plan.md." diff --git a/.codex/skills/software-engineer/references/execution-checklists.md b/.codex/skills/software-engineer/references/execution-checklists.md new file mode 100644 index 0000000..761d59f --- /dev/null +++ b/.codex/skills/software-engineer/references/execution-checklists.md @@ -0,0 +1,33 @@ +# Execution Checklists + +## Investigation Checklist + +- Reproduce issue with concrete inputs. +- Capture expected vs actual behavior. +- Narrow fault domain (backend, frontend, integration, data). +- Prove or disprove top hypotheses with evidence. +- Identify minimal safe fix and regression risks. + +## Small Change Checklist + +- Confirm scope boundaries and non-goals. +- Update only required modules/routes/components. +- Add or update focused tests. +- Validate public interfaces and backward compatibility. +- Verify logs/errors/telemetry remain actionable. + +## Feature Delivery Checklist + +- Read `prd.md`, `fdd.md`, and `plan.md` in order. +- Map requirements to implementation units and tests. +- Implement in plan order unless blockers force resequencing. +- Validate acceptance criteria from the PRD. +- Verify UX/API/integration behavior end-to-end. + +## LTI/LMS Checklist + +- Validate launch and auth flow assumptions. +- Confirm required claims/roles/context fields are handled. +- Check course/user identifiers and tenancy boundaries. +- Verify grade/assignment semantics where applicable. +- Confirm failure modes return clear, supportable errors. diff --git a/.github/workflows/publish-hex.yml b/.github/workflows/publish-hex.yml index 6704fe5..7329c21 100644 --- a/.github/workflows/publish-hex.yml +++ b/.github/workflows/publish-hex.yml @@ -19,7 +19,7 @@ jobs: uses: erlef/setup-beam@v1.15.4 with: otp-version: "27.1.2" - gleam-version: "1.10.0" + gleam-version: "1.14.0" rebar3-version: "3" elixir-version: "1.18.3" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c189d4..1203c73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,10 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "27.1.2" - gleam-version: "1.10.0" + gleam-version: "1.14.0" rebar3-version: "3" elixir-version: "1.18.3" - run: gleam deps download + - run: scripts/lint_conformance_matrix.sh - run: gleam test - run: gleam format --check src test diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3eba78a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# AGENTS + +## Project Scope + +- This file defines local agent guidance for the `lightbulb` repository. + +## Project Overview + +- `lightbulb` is a Gleam library for building LTI 1.3 tools. +- Core responsibilities include OIDC login, launch validation, and LTI service integrations. +- Current service coverage includes AGS (Assignments and Grades), NRPS (Names and Roles), and Deep Linking. +- Provider interfaces are designed to keep storage and HTTP concerns pluggable. + +## Architecture + +- Entry module: + - `src/lightbulb.gleam` re-exports primary APIs for consumers. +- Launch/auth core: + - `src/lightbulb/tool.gleam` handles OIDC login, JWT verification, claim validation, and message-type routing. +- Feature modules: + - `src/lightbulb/services/access_token.gleam` builds OAuth client assertions and fetches service tokens. + - `src/lightbulb/services/ags*.gleam` implements AGS line item and score workflows. + - `src/lightbulb/services/nrps*.gleam` implements membership retrieval. + - `src/lightbulb/deep_linking*.gleam` handles deep-link settings, content items, response JWT, and form-post helper. +- Provider boundary: + - `src/lightbulb/providers/data_provider.gleam` defines persistence/key/registration interfaces. + - `src/lightbulb/providers/http_provider.gleam` abstracts HTTP transport. + - `src/lightbulb/providers/memory_provider.gleam` offers in-memory development/testing storage. +- Crypto/key utilities: + - `src/lightbulb/jose.gleam` and `src/lightbulb/jwk.gleam` wrap JOSE/JWK operations. +- Tests: + - `test/lightbulb/**` contains unit and integration-style tests grouped by domain. + +## Engineering Workflow + +- Keep changes minimal and scoped to the feature/bug request. +- Prefer additive changes over broad refactors unless refactoring is required for correctness. +- Run focused tests first, then broader tests when behavior changes cross module boundaries. +- Prefer explicit `Result` error paths over exceptions or panics. +- When working from a feature `plan.md`, check off checklist items when tasks are completed. +- For any public API or client-facing behavior change, update `CHANGELOG.md` in the same change: + - add/update notes under `Unreleased` (WIP) until release day + - note the intended target version + - summarize what changed for consumers + - include concrete migration steps/code changes required in client apps + - when cutting a release, promote `Unreleased` notes into the released version section + +## Gleam Conventions + +- Prefer explicit, typed decoding and structured error types for public APIs. +- Reuse existing modules and patterns before introducing new helpers. +- For boolean early-return checks, prefer `gleam/bool.guard`. +- Prefer returning `Result(_, ErrorType)` with explicit error variants rather than raw strings. +- When string errors are needed for compatibility, provide dedicated conversion helpers + (for example `lightbulb/errors.*_to_string`) instead of ad-hoc string literals. + +Preferred pattern: + +```gleam +use <- bool.guard( + when: some_condition, + return: Error("some.error.code"), +) +``` + +- Avoid creating custom `bool_guard` wrappers when `bool.guard` is sufficient. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2aeaf3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,162 @@ +# Changelog + +This changelog serves as release notes and is maintained as WIP until a release is cut. + +## [Unreleased] (target: 2.0.0) + +### Features + +- Full LTI 1.3 Core launch support with required claim validation, audience/`azp` + handling, nonce hardening, and state/target-link consistency enforcement. +- Deep Linking support for decoding deep-link launches and building signed response + JWT/form-post payloads. +- Service integrations for AGS and NRPS workflows. +- OAuth service token typed API and error taxonomy: + - `services/access_token.fetch_access_token/3` + - `services/access_token.fetch_access_token_with_options/4` + - `services/access_token.access_token_error_to_string/1` +- Optional OAuth token cache helper: + - `services/access_token_cache` with cache keying, staleness checks, and + `fetch_access_token_with_cache/4`. +- Replaced custom internal logger FFI implementation with the `logging` (`v1.3.0`) + package while preserving `lightbulb/utils/logger` call sites. +- AGS API expansion and hardening (target: `2.0.0`): + - New typed AGS errors and string conversion helper: + `services/ags.AgsError`, `services/ags.ags_error_to_string/1` + - Full line item service coverage: + `get_line_item/3`, `list_line_items/4`, `update_line_item/3`, + `delete_line_item/3`, `create_line_item/6`, + `fetch_or_create_line_item/7` + - Results service support: + `list_results/4` with `ResultsQuery` + - Paging metadata support: + `http/link_header` parser and `services/ags.Paged(a)` responses + - Scope helpers and guards: + `can_*` and `require_can_*` predicates for line items/scores/results + - Added AGS readonly line-item scope constant: + `lineitem_readonly_scope_url` +- NRPS API expansion and hardening (target: `2.0.0`): + - New typed NRPS errors and conversion helper: + `services/nrps.NrpsError`, `services/nrps.nrps_error_to_string/1` + - Spec-aligned NRPS claim decoding: + `services/nrps.get_nrps_claim/1` now requires + `context_memberships_url` and `service_versions` + - Scope helpers: + `can_read_memberships/1`, `require_can_read_memberships/1` + - Options-based membership fetch and paging support: + `MembershipsQuery`, `MembershipsPage`, + `fetch_memberships_with_options/4`, + `fetch_next_memberships_page/3`, + `fetch_differences_memberships_page/3` + - Compatibility wrapper retained: + `fetch_memberships/3` still returns `List(Membership)` via default options. +- Certification governance package (target: `2.0.0`): + - Added schema-driven conformance matrix: + `docs/complete_lti_support/conformance_matrix.md` + - Added certification runbook: + `docs/complete_lti_support/certification_runbook.md` + - Added evidence package layout and dry-run records under: + `docs/complete_lti_support/evidence/` + - Added matrix lint automation: + `scripts/lint_conformance_matrix.sh` + - Added conformance-focused test modules under: + `test/lightbulb/conformance/` + - CI now enforces matrix lint in `.github/workflows/test.yml`. + +### Bug Fixes + +- Core launch validation now requires persisted login context and enforces state and + `target_link_uri` consistency between OIDC login and launch validation. +- Core audience handling now supports `aud` as string or list and enforces `azp` + semantics for multi-audience tokens. +- OAuth token decode now tolerates missing `scope` and `expires_in` fields while + preserving usable defaults. +- Non-2xx OAuth token responses now parse RFC 6749-style error bodies + (`error`, `error_description`, `error_uri`) before falling back to generic status + errors. +- OAuth client assertion defaults are hardened with short lifetime defaults (5 minutes) + and configurable audience selection. +- Replaced the `birl` dependency with direct use of `gleam/time` (`duration` and + `timestamp`) for nonce/login-context expiry and JWT timestamp handling. +- AGS score posting now accepts `200/201/202/204` success statuses and no longer + sends the line-item-container `Accept` header for score POST requests. +- `grade_passback_available/1` now reflects score-posting capability (`scope/score`) + instead of results-read capability. +- NRPS membership decode now tolerates minimal valid member payloads and no longer + requires non-normative claim fields (`errors`, `validation_context`). + +### Breaking Changes + +- `DataProvider` implementations must add: + - `save_login_context(LoginContext) -> Result(Nil, LaunchContextError)` + - `get_login_context(String) -> Result(LoginContext, LaunchContextError)` + - `consume_login_context(String) -> Result(Nil, LaunchContextError)` +- Removed provider composition helper types/functions from public API: + - `providers/data_provider.LaunchContextProvider` + - `providers/data_provider.from_parts/6` +- Migration for provider implementations: + - Construct `DataProvider(...)` directly instead of calling `from_parts/6`. +- `DataProvider` provider operations now use typed provider errors: + - `create_nonce() -> Result(Nonce, ProviderError)` + - `get_registration(String, String) -> Result(Registration, ProviderError)` + - `get_deployment(String, String, String) -> Result(Deployment, ProviderError)` + - `get_active_jwk() -> Result(Jwk, ProviderError)` +- Core APIs now return structured error types instead of string codes: + - `tool.oidc_login/2 -> Result(#(String, String), CoreError)` + - `tool.validate_launch/3 -> Result(Claims, CoreError)` + - `tool.validate_message_type/1 -> Result(Claims, CoreError)` +- Deep Linking APIs now return `DeepLinkingError` instead of string error IDs: + - `deep_linking.get_deep_linking_settings/1` + - `deep_linking.build_response_jwt/5` + - `deep_linking.build_response_form_post/2` + - `deep_linking/content_item.validate_items/2` +- `DataProvider.validate_nonce/1` now returns `Result(Nil, NonceError)` and must map + nonce outcomes to explicit variants (`NonceInvalid`, `NonceExpired`, `NonceReplayed`). +- Clients relying on string matching should migrate to variant matching, or explicitly + convert to short user-facing messages via `lightbulb/errors.{core_error_to_string}` + and `lightbulb/errors.{nonce_error_to_string}`. +- For deep-linking user-facing messages, use + `lightbulb/errors.{deep_linking_error_to_string}`. +- AGS `LineItem` constructor now includes additional optional AGS fields: + `resource_link_id`, `tag`, `start_date_time`, `end_date_time`, + `grades_released`. +- Migration for OAuth callers: + - `fetch_access_token/3` now returns typed errors: + `Result(AccessToken, AccessTokenError)`. + - If you need user-facing string messages, convert with + `access_token_error_to_string/1` at process boundaries. + - If you need custom assertion audience or TTL, switch to + `fetch_access_token_with_options/4`. + - To reduce token endpoint traffic, adopt `services/access_token_cache` and route + service-token fetches through `fetch_access_token_with_cache/4`. +- Migration for AGS callers: + - AGS APIs return typed errors (`AgsError`) and no longer provide + string-error compatibility wrappers. + - Pattern-match on `AgsError`, or convert with `ags_error_to_string/1` + at process boundaries. + - If you construct `LineItem` directly, add the new optional fields with + `option.None` unless needed. +- Migration for NRPS callers: + - `fetch_memberships/3` now returns `Result(List(Membership), NrpsError)` + (typed errors instead of string errors). + - Pattern-match on `NrpsError`, or convert at process boundaries with + `nrps_error_to_string/1`. + - If you construct `Membership` directly, use the new shape: + required: `user_id`, `roles`; optional: `status`, `name`, `given_name`, + `family_name`, `middle_name`, `email`, `picture`, `lis_person_sourcedid`. + - For filtering/paging flows, move from `fetch_memberships/3` to + `fetch_memberships_with_options/4` and continuation helpers. + +## 1.0.0 + +### Features + +- Initial stable release of the Lightbulb LTI 1.3 library. + +### Bug Fixes + +- None noted. + +### Breaking Changes + +- None. diff --git a/README.md b/README.md index abfefcd..f47bab2 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ A library for building LTI 1.3 tools in Gleam [![Package Version](https://img.shields.io/hexpm/v/lightbulb)](https://hex.pm/packages/lightbulb) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/lightbulb/) +For API changes across versions, see [`CHANGELOG.md`](./CHANGELOG.md). + ### Installation ```sh -gleam add lightbulb@1 +gleam add lightbulb@2 ``` ### Usage @@ -26,7 +28,9 @@ import gleam/http/response import gleam/list import gleam/option.{Some} import gleam/string -import lightbulb.{type DataProvider} +import lightbulb/errors +import lightbulb/providers/data_provider.{type DataProvider} +import lightbulb/tool import wisp.{type Request, type Response, redirect} pub fn oidc_login(req: Request, data_provider: DataProvider) -> Response { @@ -34,7 +38,7 @@ pub fn oidc_login(req: Request, data_provider: DataProvider) -> Response { use params <- all_params(req) // Build the OIDC login state and URL response. - case lightbulb.oidc_login(data_provider, params) { + case tool.oidc_login(data_provider, params) { Ok(#(state, redirect_url)) -> { use <- set_cookie( "state", @@ -50,7 +54,9 @@ pub fn oidc_login(req: Request, data_provider: DataProvider) -> Response { } Error(error) -> wisp.internal_server_error() - |> wisp.string_body("OIDC login failed: " <> error) + |> wisp.string_body( + "OIDC login failed: " <> errors.core_error_to_string(error), + ) } } @@ -65,14 +71,16 @@ pub fn validate_launch(req: Request, data_provider: DataProvider) -> Response { }) // Validate the launch request using the parameters and state. - case lightbulb.validate_launch(data_provider, params, state) { + case tool.validate_launch(data_provider, params, state) { Ok(claims) -> { wisp.ok() |> wisp.string_body("Launch successful! " <> string.inspect(claims)) } Error(e) -> { wisp.bad_request() - |> wisp.string_body("Invalid launch: " <> string.inspect(e)) + |> wisp.string_body( + "Invalid launch: " <> errors.core_error_to_string(e), + ) } } } @@ -125,7 +133,51 @@ fn get_cookie(req: Request, name name: String) -> Result(String, Nil) { ``` -Further documentation can be found at . +### Typical Integration Flow + +1. Implement required providers: + - `DataProvider`: [lightbulb/providers/data_provider](./lightbulb/providers/data_provider.html) + - `HttpProvider`: [lightbulb/providers/http_provider](./lightbulb/providers/http_provider.html) +2. Handle OIDC login with `tool.oidc_login`. +3. Validate launch requests with `tool.validate_launch`. +4. Dispatch by LTI message type and feature: + - Resource link launches: AGS/NRPS flows as needed. + - Deep-link launches: decode settings and return a signed deep-link response. +5. For service calls, fetch OAuth tokens via `services/access_token`. + +### AGS (Assignments and Grades) + +[AGS module](./lightbulb/services/ags.html) includes full AGS line-item CRUD, results retrieval, +scope helpers, and pagination metadata support. + +### NRPS (Names and Roles) + +[NRPS module](./lightbulb/services/nrps.html) includes NRPS APIs for claim decode, +scope checks, filtered membership fetches, and pagination links. + +### Deep Linking + +[Deep Linking module](./lightbulb/deep_linking.html) includes support for decoding deep-link launch +claims, building signed response JWTs, and constructing form-post payloads for the response. + +### OAuth Service Tokens + +[OAuth Service Tokens module](./lightbulb/services/access_token.html) provides utilities for fetching and caching OAuth access tokens for LTI services (AGS, NRPS, etc.). + +### Data Providers + +`lightbulb` requires two provider interfaces: + +- `DataProvider` for nonce, launch context, registration/deployment, and JWK + persistence used by OIDC login and launch validation. +- `HttpProvider` for outbound HTTP transport used by service modules (AGS, NRPS, + OAuth token requests). + +See module documentation: + +- [Data Provider module](./lightbulb/providers/data_provider.html) +- [HTTP Provider module](./lightbulb/providers/http_provider.html) +- [Memory Provider module](./lightbulb/providers/memory_provider.html) (in-memory implementation for development/testing) ## Development diff --git a/docs/complete_lti_support/certification_runbook.md b/docs/complete_lti_support/certification_runbook.md new file mode 100644 index 0000000..a6071a4 --- /dev/null +++ b/docs/complete_lti_support/certification_runbook.md @@ -0,0 +1,97 @@ +# Certification Runbook + +This runbook defines repeatable execution for internal pre-flight and validator-facing certification evidence. + +## Preconditions And Environment Setup + +- Required tooling: + - Erlang/OTP `27.1.2` + - Gleam `1.10.0` +- Required repository state: + - clean working tree for dry-run execution evidence + - up-to-date `docs/complete_lti_support/conformance_matrix.md` +- Required commands: + - `gleam deps download` + - `scripts/lint_conformance_matrix.sh` + - `gleam test` + +## Registration And Deployment Setup Checklist + +- Confirm platform registration exists for the target LMS issuer/client pair. +- Confirm deployment IDs mapped in test fixtures are valid for launch validation scenarios. +- Confirm signing key/JWKS retrieval path is valid in provider configuration. +- Confirm nonce and login-context persistence providers are reachable. + +## Endpoint Mapping Checklist + +- OIDC login initiation endpoint is reachable and records login context. +- Launch validation endpoint accepts `id_token` and `state` and returns deterministic errors. +- AGS line item, score, and result service URLs resolve with expected auth scopes. +- NRPS memberships endpoint resolves with expected auth scope. +- Deep Linking return URL endpoint accepts form-post `JWT`. + +## Protocol Execution Order + +Execute in this order: + +1. Core launch flow (OIDC login + launch validation, positive and negative). +2. NRPS flow (claim decode, scope guard, memberships retrieval/paging). +3. AGS flow (scope guard, line item operations, score publish, results retrieval). +4. Deep Linking flow (settings decode, response JWT build/sign, form-post payload). + +## Test Account And Data Requirements + +- One instructor account and one learner account in LMS test tenant. +- Course/context with: + - at least one resource link + - AGS enabled + - NRPS enabled +- Tool registration with deployment enabled for the test context. + +## Failure Triage Playbook + +- If `scripts/lint_conformance_matrix.sh` fails: + - fix missing schema values, requirement coverage, or broken refs before rerun. +- If a conformance test fails: + - identify matrix row via `test_refs` + - open remediation task and link it in evidence notes + - rerun `gleam test` after fix and update `last_verified_date`. +- If validator dry-run fails: + - capture request/response excerpts with redaction + - map failure to requirement row(s) + - keep row at `implemented` until rerun passes. + +## Submission And Evidence Packaging Checklist + +- `docs/complete_lti_support/conformance_matrix.md` is current. +- All in-scope rows are `verified` or have tracked remediation links. +- `docs/complete_lti_support/evidence/summary.md` is updated. +- Domain evidence files exist under: + - `docs/complete_lti_support/evidence/core/` + - `docs/complete_lti_support/evidence/nrps/` + - `docs/complete_lti_support/evidence/ags/` + - `docs/complete_lti_support/evidence/deep_linking/` + +## Dry-Run Procedure Template + +Copy this block into a dated evidence file for each run: + +```md +# Dry Run: YYYY-MM-DD + +- validator_test_id: +- execution_datetime_utc: +- environment_commit_sha: +- commands: + - scripts/lint_conformance_matrix.sh + - gleam test +- request_response_excerpts: +- disposition: pass|fail +- notes: +``` + +## Ownership Model + +- Matrix steward: `lightbulb-maintainers` +- Feature owners update rows during implementation. +- Release owner validates `verified` rows and evidence freshness at cut time. diff --git a/docs/complete_lti_support/conformance_matrix.md b/docs/complete_lti_support/conformance_matrix.md new file mode 100644 index 0000000..991e19a --- /dev/null +++ b/docs/complete_lti_support/conformance_matrix.md @@ -0,0 +1,64 @@ +# LTI Conformance Matrix + +This matrix tracks requirement-to-implementation, requirement-to-test, and evidence traceability for certification readiness. + +## Status Lifecycle + +- `not_started`: row created, implementation and tests not yet linked. +- `in_progress`: implementation and/or tests are actively in development. +- `implemented`: implementation and test references are linked and passing locally. +- `verified`: dry-run evidence is linked and validated via the runbook flow. + +## Governance + +- Every epic requirement in `docs/complete_lti_support/epic_prd.md` must appear in at least one row. +- `owner` is accountable for keeping refs and evidence current. +- `last_verified_date` uses ISO format `YYYY-MM-DD` and must be updated when evidence is refreshed. +- Row identity is immutable by `requirement_id + cert_reference + feature_slug`; update refs/status in place. + +## Matrix + +| requirement_id | spec_reference | cert_reference | feature_slug | implementation_refs | test_refs | status | owner | last_verified_date | evidence_refs | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| FR-CORE-01 | https://www.imsglobal.org/spec/lti/v1p3#message-type-and-required-claims | Core cert objective: required launch claim validation | core | src/lightbulb/tool.gleam | test/lightbulb/core_test.gleam::missing_required_claim_failure_test; test/lightbulb/core_test.gleam::invalid_version_failure_test; test/lightbulb/conformance/core_certification_test.gleam::core_fr_core_01_missing_roles_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| FR-CORE-02 | https://www.imsglobal.org/spec/lti/v1p3#id-token | Core cert objective: audience (`aud`) and `azp` handling | core | src/lightbulb/tool.gleam | test/lightbulb/core_test.gleam::audience_single_success_test; test/lightbulb/core_test.gleam::audience_multi_with_azp_success_test; test/lightbulb/core_test.gleam::audience_multi_without_azp_failure_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| FR-CORE-03 | https://www.imsglobal.org/spec/lti/v1p3#launch-presentation-and-message-types | Core cert objective: message type routing (`LtiResourceLinkRequest`, `LtiDeepLinkingRequest`) | core | src/lightbulb/tool.gleam | test/lightbulb/core_test.gleam::deep_linking_message_type_success_test; test/lightbulb/core_test.gleam::deep_linking_message_type_missing_settings_failure_test; test/lightbulb/conformance/core_certification_test.gleam::core_fr_core_03_invalid_message_type_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| FR-DL-01 | https://www.imsglobal.org/spec/lti-dl/v2p0#deep-linking-request-message | Deep Linking cert objective 6.2.1 | deep_linking | src/lightbulb/deep_linking.gleam; src/lightbulb/deep_linking/settings.gleam | test/lightbulb/deep_linking_test.gleam::get_deep_linking_settings_test; test/lightbulb/conformance/deep_linking_certification_test.gleam::deep_linking_fr_dl_01_missing_settings_claim_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md | +| FR-DL-02 | https://www.imsglobal.org/spec/lti-dl/v2p0#deep-linking-response-message | Deep Linking cert objectives 6.2.2-6.2.6 | deep_linking | src/lightbulb/deep_linking.gleam; src/lightbulb/deep_linking/content_item.gleam | test/lightbulb/deep_linking_test.gleam::build_response_jwt_test; test/lightbulb/deep_linking_test.gleam::build_response_form_post_test; test/lightbulb/conformance/deep_linking_certification_test.gleam::deep_linking_fr_dl_02_invalid_item_type_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md | +| FR-AGS-01 | https://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-services | AGS cert objectives 5.4.5-5.4.6 | ags | src/lightbulb/services/ags.gleam; src/lightbulb/services/ags/line_item.gleam | test/lightbulb/services/ags_test.gleam::create_line_item_test; test/lightbulb/services/ags_test.gleam::list_line_items_paging_test; test/lightbulb/services/ags_test.gleam::update_line_item_test; test/lightbulb/services/ags_test.gleam::delete_line_item_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md | +| FR-AGS-02 | https://www.imsglobal.org/spec/lti-ags/v2p0#score-service | AGS cert objective 5.4.7 | ags | src/lightbulb/services/ags.gleam; src/lightbulb/services/ags/score.gleam | test/lightbulb/services/ags_test.gleam::post_score_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md | +| FR-AGS-03 | https://www.imsglobal.org/spec/lti-ags/v2p0#result-service | AGS cert objective 5.4.8 | ags | src/lightbulb/services/ags.gleam; src/lightbulb/services/ags/result.gleam | test/lightbulb/services/ags_test.gleam::list_results_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md | +| FR-AGS-04 | https://www.imsglobal.org/spec/lti-ags/v2p0#permissions-and-scopes | AGS cert objectives 5.4.1-5.4.4 | ags | src/lightbulb/services/ags.gleam | test/lightbulb/services/ags_test.gleam::scope_helpers_test; test/lightbulb/conformance/ags_certification_test.gleam::ags_fr_ags_04_missing_score_scope_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md | +| FR-NRPS-01 | https://www.imsglobal.org/spec/lti-nrps/v2p0#name-and-role-provisioning-services | NRPS cert objective 5.3.1 | nrps | src/lightbulb/services/nrps.gleam; src/lightbulb/services/nrps/membership.gleam | test/lightbulb/services/nrps_test.gleam::get_nrps_claim_minimal_valid_test; test/lightbulb/services/nrps_test.gleam::get_nrps_claim_invalid_missing_service_versions_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md | +| FR-NRPS-02 | https://www.imsglobal.org/spec/lti-nrps/v2p0#membership-container-service | NRPS cert objectives 5.3.2-5.3.5 | nrps | src/lightbulb/services/nrps.gleam | test/lightbulb/services/nrps_test.gleam::options_query_serialization_test; test/lightbulb/services/nrps_paging_test.gleam::next_link_continuation_flow_test; test/lightbulb/conformance/nrps_certification_test.gleam::nrps_fr_nrps_02_missing_scope_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md | +| FR-PROV-01 | https://www.imsglobal.org/spec/lti/v1p3#oidc-login-request | Core cert objective: launch state and nonce provider semantics | oauth_provider | src/lightbulb/providers/data_provider.gleam; src/lightbulb/providers/memory_provider.gleam; src/lightbulb/tool.gleam | test/lightbulb/providers/memory_provider/login_context_test.gleam::login_context_round_trip_and_consume_test; test/lightbulb/core_test.gleam::replayed_nonce_failure_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| FR-CERT-01 | https://www.imsglobal.org/ltiadvantage | Certification package artifacts and pre-flight suite | certification | docs/complete_lti_support/conformance_matrix.md; docs/complete_lti_support/certification_runbook.md; scripts/lint_conformance_matrix.sh; .github/workflows/test.yml | test/lightbulb/conformance/core_certification_test.gleam::core_fr_core_01_missing_roles_rejected_test; test/lightbulb/conformance/nrps_certification_test.gleam::nrps_fr_nrps_02_missing_scope_rejected_test; test/lightbulb/conformance/ags_certification_test.gleam::ags_fr_ags_04_missing_score_scope_rejected_test; test/lightbulb/conformance/deep_linking_certification_test.gleam::deep_linking_fr_dl_01_missing_settings_claim_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/summary.md | +| FR-CERT-01 | https://www.imsglobal.org/spec/lti/v1p3/cert | Core objective mapping completeness | core | docs/complete_lti_support/conformance_matrix.md | test/lightbulb/conformance/core_certification_test.gleam::core_fr_core_03_invalid_message_type_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| FR-CERT-01 | https://www.imsglobal.org/spec/lti/v1p3/cert | NRPS objective mapping completeness | nrps | docs/complete_lti_support/conformance_matrix.md | test/lightbulb/conformance/nrps_certification_test.gleam::nrps_fr_nrps_02_scope_present_allows_read_memberships_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md | +| FR-CERT-01 | https://www.imsglobal.org/spec/lti/v1p3/cert | AGS objective mapping completeness | ags | docs/complete_lti_support/conformance_matrix.md | test/lightbulb/conformance/ags_certification_test.gleam::ags_fr_ags_04_score_scope_allows_post_scores_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md | +| FR-CERT-01 | https://www.imsglobal.org/spec/lti/v1p3/cert | Deep Linking objective mapping completeness | deep_linking | docs/complete_lti_support/conformance_matrix.md | test/lightbulb/conformance/deep_linking_certification_test.gleam::deep_linking_fr_dl_02_invalid_item_type_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md | +| NFR-01 | https://www.imsglobal.org/spec/lti/v1p3#security-framework | Security hardening: signature, nonce, state, timestamps | core | src/lightbulb/tool.gleam; src/lightbulb/nonce.gleam | test/lightbulb/core_test.gleam::invalid_signature_failure_test; test/lightbulb/core_test.gleam::expired_nonce_failure_test; test/lightbulb/core_test.gleam::state_mismatch_failure_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md | +| NFR-02 | https://www.imsglobal.org/ltiadvantage | Interoperability via tolerant optional-field decoding | oauth_provider | src/lightbulb/services/access_token.gleam; src/lightbulb/services/nrps.gleam | test/lightbulb/services/access_token_test.gleam::tolerant_decode_missing_optional_fields_test; test/lightbulb/services/nrps_test.gleam::minimal_member_decode_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/summary.md | +| NFR-03 | https://www.imsglobal.org/ltiadvantage | Backward compatibility wrappers preserved | oauth_provider | src/lightbulb/services/access_token.gleam; src/lightbulb/services/nrps.gleam | test/lightbulb/services/access_token_test.gleam::access_token_success_and_request_shape_test; test/lightbulb/services/nrps_test.gleam::fetch_memberships_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/summary.md | +| NFR-04 | https://www.imsglobal.org/spec/lti/v1p3/cert | Requirement-to-test traceability and CI-enforced matrix linting | certification | docs/complete_lti_support/conformance_matrix.md; scripts/lint_conformance_matrix.sh; .github/workflows/test.yml | test/lightbulb/conformance/core_certification_test.gleam::core_fr_core_01_missing_roles_rejected_test; test/lightbulb/conformance/ags_certification_test.gleam::ags_fr_ags_04_missing_score_scope_rejected_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/summary.md | +| NFR-05 | https://www.imsglobal.org/ltiadvantage | Maintainable module boundaries and shared utility isolation | core | src/lightbulb/http/link_header.gleam; src/lightbulb/services/ags.gleam; src/lightbulb/services/nrps.gleam | test/lightbulb/services/ags_link_header_test.gleam::malformed_link_header_test; test/lightbulb/services/nrps_paging_test.gleam::malformed_link_header_fallback_behavior_test | verified | lightbulb-maintainers | 2026-03-02 | docs/complete_lti_support/evidence/summary.md | + +## Validation Script Usage + +Run matrix lint locally: + +```sh +scripts/lint_conformance_matrix.sh +``` + +Run against a different matrix path: + +```sh +scripts/lint_conformance_matrix.sh path/to/matrix.md +``` + +The script validates: + +- required schema columns and supported `status` values +- FR/NFR coverage against `docs/complete_lti_support/epic_prd.md` +- existence of all paths referenced in `implementation_refs`, `test_refs`, and `evidence_refs` diff --git a/docs/complete_lti_support/epic_prd.md b/docs/complete_lti_support/epic_prd.md new file mode 100644 index 0000000..f0555bf --- /dev/null +++ b/docs/complete_lti_support/epic_prd.md @@ -0,0 +1,262 @@ +# Epic PRD: Full LTI 1.3 + LTI Advantage Complete Support + +## Document Control +- Product: Lightbulb (Gleam LTI tool library) +- Epic: Complete LTI 1.3 + Advantage support and certification readiness +- Status: Draft +- Last updated: 2026-02-21 +- Related docs: + - `docs/complete_lti_support/features/README.md` + +## 1. Problem Statement +Lightbulb currently supports core portions of LTI 1.3 launch, AGS, and NRPS, but does not yet provide a full specification-compliant and certification-ready implementation of: +- LTI Core 1.3 launch validation requirements +- LTI Deep Linking 2.0 +- AGS 2.0 complete service set +- NRPS 2.0 complete service set + +Without full support and conformance evidence, integrators face interoperability risks across LMS platforms, and Lightbulb cannot pursue LTI Advantage Complete certification. + +## 2. Vision +Make Lightbulb the most reliable Gleam-native foundation for building certified LTI 1.3 tools by delivering complete, spec-aligned APIs and certification-grade validation/test coverage. + +## 3. Goals +- Deliver full Tool-side support for LTI Core 1.3, Deep Linking 2.0, AGS 2.0, and NRPS 2.0. +- Provide stable, ergonomic APIs for production integration across LMS platforms. +- Achieve readiness for LTI Advantage Complete certification with auditable evidence. +- Preserve backward compatibility where practical through additive APIs and compatibility wrappers. + +## 4. Non-Goals +- Implementing platform-side LTI behavior (Lightbulb is tool-side). +- Building a hosted certification automation service. +- Guaranteeing platform-specific behavior beyond the spec and common interoperability patterns. + +## 5. Success Metrics +- 100% of required items in internal conformance matrix mapped to implementation + tests. +- 0 open P0/P1 conformance gaps at certification submission readiness checkpoint. +- All pre-flight conformance suites pass in CI for Core, Deep Linking, AGS, and NRPS. +- Successful dry-run against official validator workflows with captured evidence package. + +## 6. Users and Use Cases +### Primary users +- Tool developers using Lightbulb to integrate with LMS platforms. +- Platform integration engineers needing predictable interoperability and clear error surfaces. + +### Core use cases +- Validate OIDC login + launch securely and spec-correctly. +- Handle both resource-link launches and deep-linking launches. +- Return deep-link content selections using signed response JWTs. +- Read class rosters via NRPS with pagination/filtering. +- Create/read/update/delete AGS line items, post scores, and read results. + +## 7. Scope +### In scope +- LTI Core 1.3 validation hardening and claim typing. +- Deep Linking 2.0 request/response lifecycle support. +- AGS 2.0 complete service surface (line items, scores, results). +- NRPS 2.0 complete service surface (claim decode correctness, membership retrieval extensions). +- OAuth/provider improvements required for interoperability. +- Certification runbook, conformance matrix, and evidence workflow. + +### Out of scope +- Non-LTI product features unrelated to certification path. +- UI product work beyond example/reference integration helpers. + +## 8. Current Baseline and Known Gaps +### Already implemented +- OIDC login and launch validation entry points. +- Deployment validation against stored registration/deployment data. +- OAuth2 client-credentials token exchange using client assertion. +- AGS partial support (score post + basic line item helpers). +- NRPS partial support (membership fetch + service URL extraction). + +### Key gaps to close +- Core: + - only one launch message type currently handled in validation flow. + - required claim validation coverage is incomplete. + - `aud` handling needs robust support for string/array patterns. + - target-link consistency with login context is not yet enforced. + - nonce expiration semantics need stricter validation-time enforcement. +- Deep Linking: + - no full request/response APIs for Deep Linking 2.0. +- AGS: + - missing complete line item CRUD and results APIs. + - scope guards and pagination handling need expansion. +- NRPS: + - claim model and membership decoding need more spec-aligned tolerance. + - paging/filter/differences workflows are incomplete. +- Certification: + - negative-path conformance coverage and evidence workflow need formalization. + +## 9. Requirements + +## 9.1 Functional Requirements +### FR-CORE-01 +Launch validation must enforce required LTI 1.3 claims and message-type-specific requirements. + +### FR-CORE-02 +Launch validation must support valid audience forms (`aud` string and array with `azp` handling where applicable). + +### FR-CORE-03 +Tool must support and distinguish `LtiResourceLinkRequest` and `LtiDeepLinkingRequest`. + +### FR-DL-01 +Tool must decode deep-linking settings claim and expose a typed API. + +### FR-DL-02 +Tool must produce valid signed `LtiDeepLinkingResponse` JWT payloads and form-post helpers. + +### FR-AGS-01 +Tool must support AGS line item service operations: get/list/create/update/delete. + +### FR-AGS-02 +Tool must support AGS score service operation: post score. + +### FR-AGS-03 +Tool must support AGS result service operation: list results with filter support. + +### FR-AGS-04 +Tool must support scope-aware operation guards for all AGS operations. + +### FR-NRPS-01 +Tool must decode NRPS claim structure per spec (`context_memberships_url`, `service_versions`) with tolerant handling of optional fields. + +### FR-NRPS-02 +Tool must retrieve memberships with support for options (role filter, limit, paging, differences workflows). + +### FR-PROV-01 +Provider interfaces must support required launch-state/nonce correctness semantics for secure validation. + +### FR-CERT-01 +Repository must include certification runbook, conformance matrix, and pre-flight conformance test suites. + +## 9.2 Non-Functional Requirements +### NFR-01 Security +- Strict validation for token signature, timestamps, nonce, deployment binding, and required claim presence. +- Deterministic error mapping for invalid payload categories. + +### NFR-02 Interoperability +- Tolerant decoding for optional fields and platform variation without violating required checks. + +### NFR-03 Backward Compatibility +- Existing public APIs should remain functional where possible; new capabilities should be additive. + +### NFR-04 Testability +- Each requirement must map to automated tests and conformance-matrix coverage. + +### NFR-05 Maintainability +- New modules should separate concerns cleanly (claims, deep linking, AGS, NRPS, shared HTTP utilities). + +## 10. Dependencies +- IMS specifications and certification guides: + - https://www.imsglobal.org/spec/lti/v1p3 + - https://www.imsglobal.org/spec/lti-dl/v2p0 + - https://www.imsglobal.org/spec/lti-ags/v2p0 + - https://www.imsglobal.org/spec/lti-nrps/v2p0 + - https://www.imsglobal.org/ltiadvantage + - https://www.imsglobal.org/spec/lti/v1p3/cert +- Existing Lightbulb provider abstractions and test framework. + +## 11. Epic Deliverables by Phase + +## Phase 0: Foundation + Conformance Matrix +- Deliver typed-claims direction and shared parsing utilities. +- Publish conformance matrix and architecture notes. + +## Phase 1: Core 1.3 Hardening +- Cert-grade launch validation. +- aud/azp support. +- login-target consistency checks. +- stronger nonce/state semantics. + +## Phase 2: Deep Linking 2.0 +- Deep-link request support. +- Deep-link response JWT generation. +- content-item builders and response post helper. + +## Phase 3: AGS 2.0 Complete API +- Full line item + score + result operations. +- scope checks and pagination. + +## Phase 4: NRPS 2.0 Complete API +- Correct claim decoding and robust roster decoding. +- options for paging/filtering/differences/rlid workflows. + +## Phase 5: OAuth + Provider Improvements +- token response tolerance and error details. +- optional token caching utility. +- provider interface updates required for correctness. + +## Phase 6: Certification Package +- certification runbook. +- pre-flight suites. +- validator dry-run evidence package. + +## 12. Milestones and Exit Criteria +### M1 Core Hardening Complete +- Required claim validation coverage implemented and tested. +- Core bad-payload cases (for cert readiness) covered by automated tests. + +### M2 Deep Linking Complete +- Deep-link request/response workflows implemented and tested. +- Deep-link response JWT signing and required claim checks validated in tests. + +### M3 AGS Complete +- Full AGS operations and pagination/scope checks implemented and tested. + +### M4 NRPS Complete +- Full NRPS claim/member/paging/filter behavior implemented and tested. + +### M5 Certification Ready +- Conformance matrix complete. +- Pre-flight suites green. +- Validator dry-run evidence complete. + +## 13. Risks and Mitigations +- API churn risk. + - Mitigation: additive APIs + compatibility wrappers + migration notes. +- LMS variance risk. + - Mitigation: strict required checks + tolerant optional decode + interoperability fixtures. +- Certification negative-case failures. + - Mitigation: codify cert bad-payload cases early and run in CI. + +## 14. Resourcing and Sequencing +- Recommended order: Phase 0/1 -> Phase 2 -> Phase 3/4 (parallel possible) -> Phase 5 -> Phase 6. +- Estimated timeline: + - Sequential: ~13 weeks + - With AGS/NRPS overlap: ~8-10 weeks + +## 15. Epic Backlog Seed (for Feature Breakdown) +The next step is to create one feature PRD + implementation plan per feature. Use this structure: +- Feature slug +- Problem / user value +- Requirements in scope +- API/design changes +- Implementation tasks (file-level) +- Test plan +- Rollout and compatibility notes +- Definition of done + +Initial feature candidates: +1. `core`: claim validation, audience handling, state/target-link consistency, nonce hardening. +2. `deep_linking`: request model support, response JWT generation, content-item/form-post support. +3. `ags`: line item CRUD, results API, scope guards, paging support. +4. `nrps`: claim model correction, membership model tolerance, paging/filter/differences support. +5. `oauth_provider`: token tolerance/error mapping, provider contract evolution, optional token caching. +6. `certification`: conformance matrix, pre-flight suites, runbook and evidence package. + +## 16. Open Decisions +- Whether to extend `DataProvider` directly for launch context/state persistence vs introducing a dedicated state/session provider abstraction. +- Whether token caching ships as built-in behavior or optional utility layer. +- Minimum public API stability guarantees for v1.x vs introducing a v2 compatibility boundary. + +## 17. Appendix: Code Areas Impacted +- `src/lightbulb/tool.gleam` +- `src/lightbulb/services/ags.gleam` +- `src/lightbulb/services/ags/line_item.gleam` +- `src/lightbulb/services/nrps.gleam` +- `src/lightbulb/services/nrps/membership.gleam` +- `src/lightbulb/services/access_token.gleam` +- `src/lightbulb/providers/data_provider.gleam` +- `src/lightbulb/providers/memory_provider.gleam` +- New deep linking and shared HTTP/claims modules (to be added) diff --git a/docs/complete_lti_support/evidence/README.md b/docs/complete_lti_support/evidence/README.md new file mode 100644 index 0000000..dbe08a4 --- /dev/null +++ b/docs/complete_lti_support/evidence/README.md @@ -0,0 +1,20 @@ +# Certification Evidence Package + +This directory stores domain-level evidence artifacts referenced by the conformance matrix. + +## Required Per-Record Fields + +- `validator_test_id` (or internal objective identifier) +- `execution_datetime_utc` +- `environment_commit_sha` +- `request_response_excerpts` (redacted) +- `disposition` (`pass` or `fail`) +- `notes` (include remediation link for failures) + +## Directory Layout + +- `core/` +- `nrps/` +- `ags/` +- `deep_linking/` +- `summary.md` diff --git a/docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md b/docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md new file mode 100644 index 0000000..1ac65ca --- /dev/null +++ b/docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md @@ -0,0 +1,8 @@ +# AGS Dry Run 2026-03-02 + +- validator_test_id: ags-preflight-internal +- execution_datetime_utc: 2026-03-02T20:00:00Z +- environment_commit_sha: c5338ff +- request_response_excerpts: mocked AGS line item/score/result request and response flows +- disposition: pass +- notes: validated line item CRUD, score publish acceptance statuses, results retrieval, and scope guard failures. diff --git a/docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md b/docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md new file mode 100644 index 0000000..b823b70 --- /dev/null +++ b/docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md @@ -0,0 +1,8 @@ +# Core Dry Run 2026-03-02 + +- validator_test_id: core-preflight-internal +- execution_datetime_utc: 2026-03-02T20:00:00Z +- environment_commit_sha: c5338ff +- request_response_excerpts: local unit-test and claim-validation scenarios only; no external LMS traffic +- disposition: pass +- notes: validated required-claim, message-type, audience/azp, nonce/state, and signature failure paths via automated tests. diff --git a/docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md b/docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md new file mode 100644 index 0000000..3512f71 --- /dev/null +++ b/docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md @@ -0,0 +1,8 @@ +# Deep Linking Dry Run 2026-03-02 + +- validator_test_id: deep-linking-preflight-internal +- execution_datetime_utc: 2026-03-02T20:00:00Z +- environment_commit_sha: c5338ff +- request_response_excerpts: deep-link settings decode and response JWT/form-post generation scenarios +- disposition: pass +- notes: validated required claims, signature, timestamps, and invalid item-type rejection path. diff --git a/docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md b/docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md new file mode 100644 index 0000000..b5a6bc2 --- /dev/null +++ b/docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md @@ -0,0 +1,8 @@ +# NRPS Dry Run 2026-03-02 + +- validator_test_id: nrps-preflight-internal +- execution_datetime_utc: 2026-03-02T20:00:00Z +- environment_commit_sha: c5338ff +- request_response_excerpts: mocked NRPS request/response flows with pagination and scope checks +- disposition: pass +- notes: validated claim decode, scope guards, memberships retrieval, and continuation-link behavior. diff --git a/docs/complete_lti_support/evidence/summary.md b/docs/complete_lti_support/evidence/summary.md new file mode 100644 index 0000000..c897bae --- /dev/null +++ b/docs/complete_lti_support/evidence/summary.md @@ -0,0 +1,17 @@ +# Evidence Summary + +## Latest Internal Dry Run + +- Date: `2026-03-02` +- Scope: Core, NRPS, AGS, Deep Linking pre-flight checks +- Commands: + - `scripts/lint_conformance_matrix.sh` + - `gleam test` +- Outcome: pass + +## Evidence Index + +- Core: `docs/complete_lti_support/evidence/core/core-dry-run-2026-03-02.md` +- NRPS: `docs/complete_lti_support/evidence/nrps/nrps-dry-run-2026-03-02.md` +- AGS: `docs/complete_lti_support/evidence/ags/ags-dry-run-2026-03-02.md` +- Deep Linking: `docs/complete_lti_support/evidence/deep_linking/deep-linking-dry-run-2026-03-02.md` diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 0000000..14acd2b --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,23 @@ +# Feature Specifications + +Each feature is organized by lowercase slug under `docs/complete_lti_support/features/`. + +## Directory Contract +Each feature directory contains: +- `prd.md` (product requirements specification) +- `fdd.md` (functional design document) +- `plan.md` (phased implementation plan for end-to-end delivery) + +## Error Modeling Guidance + +- Feature docs should specify explicit typed error models (enums/variants/records), + not dot-separated string error IDs. +- If compatibility string output is needed, specify a dedicated conversion helper. + +## Features +- `core/` +- `deep_linking/` +- `ags/` +- `nrps/` +- `oauth_provider/` +- `certification/` diff --git a/docs/features/ags/fdd.md b/docs/features/ags/fdd.md new file mode 100644 index 0000000..11367a7 --- /dev/null +++ b/docs/features/ags/fdd.md @@ -0,0 +1,191 @@ +# Functional Design Document: ags + +## 1. Normative References +- AGS 2.0 spec: https://www.imsglobal.org/spec/lti-ags/v2p0/ +- LTI 1.3 certification guide (AGS tool tests section): https://www.imsglobal.org/spec/lti/v1p3/cert + +Key normative points used in this design: +- Scopes and allowed methods are defined in AGS section 3.1. +- Line item list filters include `resource_link_id`, `resource_id`, `tag`, and `limit`. +- Results service is derived from line item URL as `lineitem_url/results` and supports `user_id` narrowing. +- Paging uses HTTP `Link` headers for containers. + +## 2. Current Baseline and Gaps +Current code (`src/lightbulb/services/ags.gleam`) implements: +- `post_score` +- `create_line_item` +- `fetch_or_create_line_item` +- AGS claim decode and one availability helper + +Gaps vs AGS 2.0: +- Missing line item `get/list/update/delete` operations. +- Missing results service (`GET lineitem_url/results`) APIs. +- Missing scope constant: `https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly`. +- `grade_passback_available` currently checks only `result.readonly`, which is not the right permission check for score posting. +- Line item model is too narrow for full interoperability. +- No paging support for container responses. +- Header/media-type handling is currently oversimplified (same Accept for all line item operations). + +## 3. Target Public API + +### 3.1 Constants +- Keep existing: + - `lineitem_scope_url` + - `result_readonly_scope_url` + - `scores_scope_url` +- Add: + - `lineitem_readonly_scope_url = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"` + +### 3.2 Types +- Expand `LineItem`: + - `id: Option(String)` + - `score_maximum: Float` + - `label: String` + - `resource_id: String` + - `resource_link_id: Option(String)` + - `tag: Option(String)` + - `start_date_time: Option(String)` + - `end_date_time: Option(String)` + - `grades_released: Option(Bool)` +- Add `Result` type in `src/lightbulb/services/ags/result.gleam`: + - `id: Option(String)` + - `user_id: String` + - `result_score: Option(Float)` + - `result_maximum: Option(Float)` + - `comment: Option(String)` + - `score_of: Option(String)` +- Add query/filter types: + - `LineItemsQuery(resource_link_id: Option(String), resource_id: Option(String), tag: Option(String), limit: Option(Int))` + - `ResultsQuery(user_id: Option(String), limit: Option(Int))` +- Add paging type: + - `PageLinks(next: Option(String), prev: Option(String), first: Option(String), last: Option(String))` + - `Paged(a)(items: List(a), links: PageLinks)` + +### 3.3 Operations +Additive APIs to add in `src/lightbulb/services/ags.gleam`: +- `get_line_item(http_provider, line_item_url, access_token) -> Result(LineItem, AgsError)` +- `list_line_items(http_provider, line_items_service_url, query, access_token) -> Result(Paged(LineItem), AgsError)` +- `update_line_item(http_provider, line_item, access_token) -> Result(LineItem, AgsError)` +- `delete_line_item(http_provider, line_item_url, access_token) -> Result(Nil, AgsError)` +- `list_results(http_provider, line_item_url, query, access_token) -> Result(Paged(Result), AgsError)` + +Keep compatibility wrappers: +- Preserve existing `create_line_item`, `fetch_or_create_line_item`, `post_score` signatures. +- Existing helpers may internally call new generic operations. + +## 4. HTTP Contract and Media Types + +### 4.1 Line Item Service +- List line items: + - `GET ?resource_link_id=...&resource_id=...&tag=...&limit=...` + - `Accept: application/vnd.ims.lis.v2.lineitemcontainer+json` + - Response: `200` + line item container payload +- Get line item: + - `GET ` + - `Accept: application/vnd.ims.lis.v2.lineitem+json` + - Response: `200` +- Create line item: + - `POST ` + - `Content-Type: application/vnd.ims.lis.v2.lineitem+json` + - `Accept: application/vnd.ims.lis.v2.lineitem+json` + - Response: `201` (support `200` for tolerance) +- Update line item: + - `PUT ` + - `Content-Type: application/vnd.ims.lis.v2.lineitem+json` + - `Accept: application/vnd.ims.lis.v2.lineitem+json` + - Response: `200` (tolerate `201`) +- Delete line item: + - `DELETE ` + - Response: `204` (tolerate `200`) + +### 4.2 Score Service +- `POST /scores` +- `Content-Type: application/vnd.ims.lis.v1.score+json` +- Accept header should not force line item container media type. +- Success responses: accept `200`, `201`, `202`, `204`. + +### 4.3 Result Service +- `GET /results?user_id=...&limit=...` +- `Accept: application/vnd.ims.lis.v2.resultcontainer+json` +- Response: `200` + result container payload + +## 5. Scope Authorization Matrix +- Read line items (`get/list`): requires `lineitem` or `lineitem.readonly`. +- Write line items (`create/update/delete`): requires `lineitem`. +- Post score: requires `score`. +- Read results: requires `result.readonly`. + +Add explicit helper predicates: +- `can_read_line_items(claims)` +- `can_write_line_items(claims)` +- `can_post_scores(claims)` +- `can_read_results(claims)` + +`grade_passback_available` should be redefined to reflect score-posting capability (score scope), not results-read capability. + +## 6. Paging Design +- Add shared parser module `src/lightbulb/http/link_header.gleam`. +- Parse standard relations: `next`, `prev`, `first`, `last`. +- Container list APIs (`list_line_items`, `list_results`) return `Paged(a)` with parsed links. +- If parsing fails, operation still returns items with empty links and logs parse warning. + +## 7. Error Taxonomy +Use explicit AGS error types for public APIs (for example `AgsError`) rather than +string identifiers. + +Recommended variants: +- `RequestInvalidUrl` +- `HttpUnexpectedStatus` +- `DecodeLineItem` +- `DecodeResult` +- `ScopeInsufficient` +- `PaginationInvalidLinkHeader` + +If string output is needed for logging/UI compatibility, provide a dedicated conversion +function (for example `ags_error_to_string`). + +## 8. Runtime Flows + +### 8.1 Launch claim to operation +1. Decode AGS claim. +2. Validate operation scope with predicate helper. +3. Resolve service URL (`lineitems`, `lineitem`, or derived `lineitem/results`, `lineitem/scores`). +4. Execute HTTP call with operation-specific headers. +5. Decode payload and paging metadata. + +### 8.2 Fetch-or-create compatibility path +1. `list_line_items` with `resource_id` and `limit=1`. +2. If empty, call `create_line_item`. +3. Return first line item. + +## 9. Testability Design +- Unit tests: + - Scope helpers for all operation categories. + - Query builder behavior with and without existing query strings. + - Line item/result/claim decoders with optional fields. + - Link header parser variations. +- Integration-style tests with `http_mock_provider`: + - CRUD + results request construction and response handling. + - Paging link extraction. +- Negative-path tests: + - Wrong media type payload shape. + - Unexpected status per operation. + - Missing scope for operation. + - Malformed Link header. + +## 10. File-Level Design Impact +- `src/lightbulb/services/ags.gleam` +- `src/lightbulb/services/ags/line_item.gleam` +- `src/lightbulb/services/ags/result.gleam` (new) +- `src/lightbulb/http/link_header.gleam` (new) +- `test/lightbulb/services/ags_test.gleam` +- Potentially: `test/lightbulb/services/access_token_test.gleam` for scope-related assumptions + +## 11. Certification Traceability (AGS) +Implementation and tests should explicitly map to AGS tool test objectives from the LTI 1.3 certification guide section 5.4, including: +- Claim availability and endpoint claim handling. +- Scope handling in access-token requests. +- Access-token retrieval and use for AGS calls. +- Line item creation and retrieval flows. +- Score publish flow. +- Result retrieval flow. diff --git a/docs/features/ags/plan.md b/docs/features/ags/plan.md new file mode 100644 index 0000000..db56c74 --- /dev/null +++ b/docs/features/ags/plan.md @@ -0,0 +1,105 @@ +# Implementation Plan: ags + +## Phase 1: Core Line Item API Completion +- [x] Expand `LineItem` type and encoder/decoder in `src/lightbulb/services/ags/line_item.gleam` to include optional spec fields. +- [x] Add operation-specific header helpers in `src/lightbulb/services/ags.gleam`: + - [x] list line items headers (`Accept: lineitemcontainer`) + - [x] get/create/update line item headers (`Accept: lineitem`, `Content-Type: lineitem` for writes) +- [x] Implement new APIs in `src/lightbulb/services/ags.gleam`: + - [x] `get_line_item` + - [x] `list_line_items` + - [x] `update_line_item` + - [x] `delete_line_item` +- [x] Implement query builder for list filters: + - [x] `resource_link_id` + - [x] `resource_id` + - [x] `tag` + - [x] `limit` +- [x] Preserve and adapt existing helpers: + - [x] `create_line_item` delegates to new primitives where possible + - [x] `fetch_or_create_line_item` uses `list_line_items(... limit=1 ...)` + +## Phase 2: Results Service Implementation +- [x] Add `src/lightbulb/services/ags/result.gleam` with `Result` type and decoder. +- [x] Add result container decoder in `src/lightbulb/services/ags.gleam`. +- [x] Implement `list_results(http_provider, line_item_url, query, access_token)`. +- [x] Support results filters: + - [x] `user_id` + - [x] `limit` +- [x] Use derived results URL `/results` preserving existing query parameters. + +## Phase 3: Scope Guards and Availability Semantics +- [x] Add scope constant `lineitem.readonly` in `src/lightbulb/services/ags.gleam`. +- [x] Add scope helper predicates: + - [x] `can_read_line_items` + - [x] `can_write_line_items` + - [x] `can_post_scores` + - [x] `can_read_results` +- [x] Update `grade_passback_available` semantics to align with score-posting capability. +- [x] Ensure operation entry points fail fast with explicit typed scope errors (for example `ScopeInsufficient`) when called against launch claims lacking required scope. + +## Phase 4: Paging Support via Link Headers +- [x] Add `src/lightbulb/http/link_header.gleam` parser for `next/prev/first/last` link relations. +- [x] Add shared `PageLinks` and `Paged(a)` types (module placement per implementation preference). +- [x] Update `list_line_items` and `list_results` to return paging metadata. +- [x] Add graceful fallback behavior when link header parsing fails. + +## Phase 5: Score Posting Hardening +- [x] Update score headers to stop forcing line item container Accept header. +- [x] Validate `post_score` success statuses include `200/201/202/204`. +- [x] Add explicit error category mapping for invalid line item ID, request failures, and unexpected status responses. +- [x] Verify compatibility of existing score payload model with target LMS payloads and add optionality only if needed. + +## Phase 6: Test Matrix and Conformance Coverage +- [x] Expand `test/lightbulb/services/ags_test.gleam` for: + - [x] line item get/list/create/update/delete request and response validation + - [x] results list request/response validation + - [x] score post status handling variants + - [x] query filter serialization coverage +- [x] Add paging parser tests (new test module for `link_header` parser). +- [x] Add scope guard tests for all operation classes. +- [x] Add negative-path tests: + - [x] malformed line item payload + - [x] malformed result payload + - [x] missing/insufficient scopes + - [x] malformed link header +- [x] Map all tests to AGS rows in `docs/complete_lti_support/conformance_matrix.md`. +- [x] Add AGS certification-targeted tests aligned to LTI cert guide section 5.4: + - [x] `5.4.1` AGS claim availability handling + - [x] `5.4.2` AGS endpoint claim handling + - [x] `5.4.3` access token scope request behavior + - [x] `5.4.4` access token retrieval behavior for AGS calls + - [x] `5.4.5` line item create behavior + - [x] `5.4.6` line item retrieval behavior + - [x] `5.4.7` score publish behavior + - [x] `5.4.8` result retrieval behavior + +## Phase 7: Documentation and Migration +- [x] Update `README.md` and module docs with new AGS API surface and examples. +- [x] Document compatibility behavior for existing functions (`create_line_item`, `fetch_or_create_line_item`, `post_score`). +- [x] Add upgrade notes for new return types (if paged responses are additive/new functions). + +## Cross-Feature Dependencies +- `core`: AGS claim extraction and launch validation prerequisites come from core launch handling. +- `oauth_provider`: AGS scope request and token acquisition behavior depends on OAuth/client-assertion correctness. +- `nrps`: shared Link-header parsing utility should be compatible with NRPS paging/differences handling. +- `certification`: AGS objective mappings (`5.4.x`) and evidence links must be maintained in conformance artifacts. + +## File-Level Execution +- `src/lightbulb/services/ags.gleam` +- `src/lightbulb/services/ags/line_item.gleam` +- `src/lightbulb/services/ags/result.gleam` (new) +- `src/lightbulb/http/link_header.gleam` (new) +- `test/lightbulb/services/ags_test.gleam` +- `test/lightbulb/services/ags_link_header_test.gleam` (new) +- `docs/complete_lti_support/conformance_matrix.md` +- `README.md` + +## Definition of Done +- [x] AGS line item service covers get/list/create/update/delete with documented request/response contracts. +- [x] AGS result service implemented with required filters and tests. +- [x] Score service behavior hardened and tested for expected status variants. +- [x] Scope guards implemented for each operation category. +- [x] Paging metadata is available for container responses. +- [x] Negative-path and conformance-mapped tests are in place. +- [x] Public docs updated with examples and migration notes. diff --git a/docs/features/ags/prd.md b/docs/features/ags/prd.md new file mode 100644 index 0000000..935f3c9 --- /dev/null +++ b/docs/features/ags/prd.md @@ -0,0 +1,29 @@ +# Product Requirements Document: ags + +## 1. Problem / User Value +AGS support is currently partial. Tools need complete line item, score, and result APIs plus robust scope and paging behavior. + +## 2. Scope +### In scope +- Full line item service support (get/list/create/update/delete). +- Score service support (post score hardening). +- Result service support (list with filters). +- Scope guards for AGS operations. +- Paging support via Link headers. + +### Out of scope +- NRPS service features. + +## 3. Requirements in Scope +- FR-AGS-01 +- FR-AGS-02 +- FR-AGS-03 +- FR-AGS-04 +- NFR-02 +- NFR-04 +- NFR-05 + +## 4. Success Criteria +- Complete AGS API available and documented. +- Scope gating and paging semantics implemented. +- AGS conformance tests (positive + negative) pass. diff --git a/docs/features/certification/fdd.md b/docs/features/certification/fdd.md new file mode 100644 index 0000000..16648fa --- /dev/null +++ b/docs/features/certification/fdd.md @@ -0,0 +1,171 @@ +# Functional Design Document: certification + +## 1. Normative References + +- LTI 1.3 certification guide: https://www.imsglobal.org/spec/lti/v1p3/cert +- LTI Advantage overview: https://www.imsglobal.org/ltiadvantage +- Component specs used for traceability: + - Core: https://www.imsglobal.org/spec/lti/v1p3 + - Deep Linking: https://www.imsglobal.org/spec/lti-dl/v2p0 + - AGS: https://www.imsglobal.org/spec/lti-ags/v2p0 + - NRPS: https://www.imsglobal.org/spec/lti-nrps/v2p0 + +## 2. Current Baseline and Gaps + +Current repository state: + +- Feature-level PRD/FDD/plan docs exist. +- CI (`.github/workflows/test.yml`) runs `gleam test` and formatting checks. + +Current gaps for certification readiness: + +- No `docs/complete_lti_support/conformance_matrix.md` artifact yet. +- No `docs/complete_lti_support/certification_runbook.md` artifact yet. +- No standardized evidence package directory or checklist. +- No explicit certification-focused test grouping and traceability metadata. +- No CI gate that validates requirement-to-test mapping completeness. + +## 3. Target Artifacts and Contracts + +### 3.1 Conformance Matrix Artifact + +Create: `docs/complete_lti_support/conformance_matrix.md` + +Required schema (table columns): + +- `requirement_id` (e.g., `FR-CORE-01`, `NFR-04`) +- `spec_reference` (URL + section) +- `cert_reference` (guide section/test objective) +- `feature_slug` (`core`, `deep_linking`, `ags`, `nrps`, `oauth_provider`, `certification`) +- `implementation_refs` (file paths) +- `test_refs` (test file paths + test IDs) +- `status` (`not_started`, `in_progress`, `implemented`, `verified`) +- `owner` +- `last_verified_date` +- `evidence_refs` (paths under evidence package) + +Rules: + +- Every FR/NFR from epic PRD must have >= 1 row. +- Rows are immutable by ID once created; updates should modify status/refs. + +### 3.2 Certification Runbook Artifact + +Create: `docs/complete_lti_support/certification_runbook.md` + +Required sections: + +- Preconditions and environment setup +- Registration/deployment setup checklist +- Endpoint mapping checklist +- Protocol execution order (Core -> NRPS -> AGS -> Deep Linking) +- Test account/data setup requirements +- Failure triage playbook +- Submission/evidence packaging checklist + +### 3.3 Evidence Package Structure + +Create root: `docs/complete_lti_support/evidence/` + +Proposed layout: + +- `docs/complete_lti_support/evidence/README.md` +- `docs/complete_lti_support/evidence/core/` +- `docs/complete_lti_support/evidence/nrps/` +- `docs/complete_lti_support/evidence/ags/` +- `docs/complete_lti_support/evidence/deep_linking/` +- `docs/complete_lti_support/evidence/summary.md` + +Per-test evidence payload: + +- validator test id/objective +- execution date/time +- environment commit SHA +- request/response excerpts (redacted) +- pass/fail disposition +- notes and remediation link + +## 4. Pre-Flight Conformance Test Design + +### 4.1 Test Organization + +Create dedicated conformance-focused test modules: + +- `test/lightbulb/conformance/core_*_test.gleam` +- `test/lightbulb/conformance/nrps_*_test.gleam` +- `test/lightbulb/conformance/ags_*_test.gleam` +- `test/lightbulb/conformance/deep_linking_*_test.gleam` + +Test naming convention: + +- `___test` + +### 4.2 Coverage Requirements + +- Positive-path and negative-path tests for each cert objective. +- Every conformance test linked to one matrix row. +- Fail build if matrix references missing test IDs/files. + +### 4.3 CI Gating + +Current CI runs `gleam test`; extend with certification governance checks: + +- matrix lint check (required columns and valid statuses) +- matrix-to-test-reference existence check +- run conformance test modules as part of `gleam test` + +## 5. Runtime Certification Workflow + +1. Implement feature work and tests. +2. Update conformance matrix rows to `implemented` with code/test refs. +3. Run CI pre-flight suite. +4. Execute manual/validator dry-run per runbook. +5. Collect evidence artifacts and link into matrix rows. +6. Mark matrix rows `verified`. +7. Generate submission summary. + +## 6. Failure and Escalation Semantics + +- Missing required matrix mapping -> release blocker. +- Matrix row without test reference -> release blocker. +- Failing conformance test -> release blocker. +- Validator dry-run failures require linked remediation task before re-run. + +## 7. Automation and Tooling + +### 7.1 Matrix Lint Script + +Add script (language/tool TBD) that validates: + +- required columns present +- allowed status values only +- all requirement IDs covered +- referenced files exist + +### 7.2 Evidence Index Script (Optional) + +Generate `evidence/summary.md` from evidence folder metadata. + +## 8. Testability Design + +- Unit-level tests for matrix parser/lint script (if script is included). +- Integration-level CI test proving failure on missing matrix coverage. +- Manual verification checklist in runbook for validator execution repeatability. + +## 9. File-Level Design Impact + +- `docs/complete_lti_support/conformance_matrix.md` (new) +- `docs/complete_lti_support/certification_runbook.md` (new) +- `docs/complete_lti_support/evidence/` (new) +- `test/lightbulb/conformance/*` (new) +- `.github/workflows/test.yml` +- optional tooling under `scripts/` for matrix validation + +## 10. Certification Traceability (Meta) + +This feature is the traceability backbone for all protocol features. +Every objective in Core/NRPS/AGS/Deep Linking certification flows must map to: + +- implementation path +- automated test(s) +- evidence artifact(s) diff --git a/docs/features/certification/plan.md b/docs/features/certification/plan.md new file mode 100644 index 0000000..fbe7fcd --- /dev/null +++ b/docs/features/certification/plan.md @@ -0,0 +1,88 @@ +# Implementation Plan: certification + +## Phase 1: Conformance Matrix Foundations +- [x] Create `docs/complete_lti_support/conformance_matrix.md` with agreed schema columns. +- [x] Seed rows for all epic requirements (FR + NFR). +- [x] Add initial mapping rows for domain cert objectives: + - [x] Core + - [x] NRPS + - [x] AGS + - [x] Deep Linking +- [x] Add status lifecycle definitions (`not_started`, `in_progress`, `implemented`, `verified`). +- [x] Add owner and verification-date governance rules. + +## Phase 2: Matrix Validation Automation +- [x] Add lightweight matrix lint script under `scripts/` (implementation language TBD). +- [x] Validate required columns and status values. +- [x] Validate every FR/NFR has at least one row. +- [x] Validate referenced files/tests exist. +- [x] Add script usage docs in `docs/complete_lti_support/conformance_matrix.md`. + +## Phase 3: Pre-Flight Conformance Suite Structure +- [x] Create conformance test folder structure: + - [x] `test/lightbulb/conformance/core_*_test.gleam` + - [x] `test/lightbulb/conformance/nrps_*_test.gleam` + - [x] `test/lightbulb/conformance/ags_*_test.gleam` + - [x] `test/lightbulb/conformance/deep_linking_*_test.gleam` +- [x] Define test naming convention tied to requirement/cert objective IDs. +- [x] Add missing negative-path tests required for certification readiness. +- [x] Ensure conformance tests run under existing `gleam test` command. + +## Phase 4: CI Quality Gates +- [x] Update `.github/workflows/test.yml` to run matrix lint in CI. +- [x] Fail CI when matrix lint fails. +- [x] Fail CI when conformance tests fail. +- [x] Keep existing format/test checks intact. + +## Phase 5: Runbook Authoring +- [x] Create `docs/complete_lti_support/certification_runbook.md`. +- [x] Add sections: + - [x] environment prerequisites + - [x] platform registration/deployment setup + - [x] endpoint mapping + - [x] execution sequence by domain + - [x] failure triage + - [x] submission checklist +- [x] Add date-stamped dry-run procedure template. + +## Phase 6: Evidence Package Design +- [x] Create `docs/complete_lti_support/evidence/` structure. +- [x] Add evidence template/checklist per cert objective. +- [x] Add `evidence/summary.md` rollup format. +- [x] Optionally add script to generate summary index from evidence metadata. + +## Phase 7: Dry Run and Verification Loop +- [x] Execute internal dry-run following runbook. +- [x] Capture evidence artifacts for each objective. +- [x] Link evidence paths in conformance matrix rows. +- [x] Move eligible rows to `verified`. +- [x] Log open failures/remediations and rerun. + +## Phase 8: Documentation and Handoff +- [x] Update `README.md` with certification readiness pointers. +- [x] Add maintainer instructions for keeping matrix current during feature work. +- [x] Publish ownership model for ongoing certification upkeep. + +## Cross-Feature Dependencies +- `core`: provides launch/security conformance objectives and evidence inputs. +- `deep_linking`: provides deep-link objective mappings (`6.2.x`) and response-evidence artifacts. +- `ags`: provides AGS objective mappings (`5.4.x`) and service-interaction evidence. +- `nrps`: provides NRPS objective mappings (`5.3.x`) and roster-evidence artifacts. +- `oauth_provider`: provides token/provider correctness evidence that underpins core/ags/nrps objectives. + +## File-Level Execution +- `docs/complete_lti_support/conformance_matrix.md` (new) +- `docs/complete_lti_support/certification_runbook.md` (new) +- `docs/complete_lti_support/evidence/` (new) +- `test/lightbulb/conformance/*` (new) +- `.github/workflows/test.yml` +- `scripts/*` (new, optional) +- `README.md` + +## Definition of Done +- [x] Conformance matrix exists with complete FR/NFR + cert objective coverage. +- [x] Matrix lint automation exists and is enforced in CI. +- [x] Conformance test suite structure exists and runs in CI. +- [x] Certification runbook exists and is actionable. +- [x] Evidence package structure and templates exist. +- [x] At least one full dry-run completed and documented with linked evidence. diff --git a/docs/features/certification/prd.md b/docs/features/certification/prd.md new file mode 100644 index 0000000..5b777f9 --- /dev/null +++ b/docs/features/certification/prd.md @@ -0,0 +1,22 @@ +# Product Requirements Document: certification + +## 1. Problem / User Value +Certification requires explicit requirement-to-test traceability, repeatable pre-flight suites, and a runbook/evidence package for validator execution. + +## 2. Scope +### In scope +- Conformance matrix artifact. +- Pre-flight suite structure and CI gating. +- Certification runbook and evidence checklist/package. + +### Out of scope +- Protocol implementation work for Core/Deep Linking/AGS/NRPS itself. + +## 3. Requirements in Scope +- FR-CERT-01 +- NFR-04 + +## 4. Success Criteria +- Conformance matrix complete and mapped to code/tests. +- Pre-flight suites pass in CI. +- Validator dry-run can be executed from runbook with complete evidence output. diff --git a/docs/features/core/fdd.md b/docs/features/core/fdd.md new file mode 100644 index 0000000..3efec1c --- /dev/null +++ b/docs/features/core/fdd.md @@ -0,0 +1,183 @@ +# Functional Design Document: core + +## 1. Normative References +- LTI Core 1.3: https://www.imsglobal.org/spec/lti/v1p3 +- LTI 1.3 certification guide (Core tool tests): https://www.imsglobal.org/spec/lti/v1p3/cert +- OpenID Connect Core (aud/azp semantics): https://openid.net/specs/openid-connect-core-1_0.html + +Key normative points used in this design: +- OIDC login request requires `iss`, `login_hint`, and `target_link_uri`; tool redirects to platform auth endpoint with required OIDC parameters. +- LTI message claim must identify launch message type. +- Resource link launch requires core claims including deployment, target link, roles, and resource link claim. +- Launch target link URI claim must match the target link URI used during login. +- `aud` may be string or array; when multiple audiences are present, `azp` rules apply. + +## 2. Current Baseline and Gaps +Current code (`src/lightbulb/tool.gleam`) implements: +- OIDC login URL generation with state and nonce. +- ID token signature verification via platform JWKS. +- Deployment validation, timestamp checks (`exp`, `iat`), nonce validation. +- Message validation for `LtiResourceLinkRequest` only. + +Gaps vs Core + certification readiness: +- Required launch claim validation is incomplete (version, roles, resource_link.id, target_link_uri claim). +- `aud` is decoded as string only; no array + `azp` resolution. +- No persistent login context for secure target-link consistency check. +- Nonce expiration is not enforced at validation-time in memory provider. +- Test coverage is missing for core launch (no existing `tool` tests). +- JWKS fetch request uses a non-standard Accept header value and should be normalized. + +## 3. Target Public API and Contract + +### 3.1 Public API Stability +Preserve existing public entry points: +- `lightbulb/tool.oidc_login(provider, params)` +- `lightbulb/tool.validate_launch(provider, params, session_state)` + +Additive extensions only where needed for context persistence and stricter validation. + +### 3.2 Provider Contract Evolution +Current `DataProvider` contract lacks state/login-context persistence hooks. +Required capability additions (either directly in `DataProvider` or via new provider composed into `validate_launch` path): +- Store login context by `state` at OIDC login time. +- Retrieve and consume login context by `state` at launch validation time. + +Minimum context fields: +- `state` +- `target_link_uri` +- `issuer` +- `client_id` +- `nonce` (optional if nonce remains separate) +- `expires_at` + +Compatibility requirement: +- Preserve current provider shape for existing users through adapters/default no-op wrappers where possible. + +## 4. Required Claim Validation Matrix + +### 4.1 OIDC Login Request Inputs +Validate inbound login request parameters before redirect generation: +- required: `iss`, `login_hint`, `target_link_uri`, `client_id` +- optional pass-through: `lti_message_hint` + +### 4.2 Launch Token Structural and Signature Validation +- required request params: `id_token`, `state` +- verify JWT signature using platform JWK selected by header `kid` +- verify registration/deployment binding using trusted registration data and deployment lookup + +### 4.3 Launch Claim Validation (Resource Link) +Enforce for `LtiResourceLinkRequest`: +- `https://purl.imsglobal.org/spec/lti/claim/message_type == "LtiResourceLinkRequest"` +- `https://purl.imsglobal.org/spec/lti/claim/version == "1.3.0"` +- `https://purl.imsglobal.org/spec/lti/claim/deployment_id` present +- `https://purl.imsglobal.org/spec/lti/claim/target_link_uri` present +- `https://purl.imsglobal.org/spec/lti/claim/resource_link.id` present +- `https://purl.imsglobal.org/spec/lti/claim/roles` present and valid list + +### 4.4 Time-Based Validation +- enforce `exp` not expired +- enforce `iat` not unreasonably in future +- optional hardening: support `nbf` when present +- use configurable skew window (recommend 60s; current 2s is too strict for distributed systems) + +## 5. Audience and Registration Resolution + +### 5.1 Decoding Rules +Support: +- `aud` as string +- `aud` as list of strings + +### 5.2 Resolution Algorithm +1. Parse `iss` from claims. +2. Parse `aud` shape. +3. If `aud` string: candidate client_id = `aud`. +4. If `aud` list: + - if exactly one value and matches known registration client_id, use it + - if multiple values, require `azp`; candidate client_id = `azp` + - validate candidate client_id exists in `aud` +5. Resolve registration by `(iss, client_id)`. + +### 5.3 Failure Modes +- missing/invalid `aud` -> explicit core audience error +- missing/invalid `azp` for multi-audience token -> explicit core audience error +- no matching registration -> explicit registration binding error + +## 6. State, Target Link, and Nonce Semantics + +### 6.1 State and Login Context +- Verify request `state` equals session/cookie state. +- Retrieve stored login context for this state and ensure not expired. +- Enforce launch `target_link_uri` claim equals stored login `target_link_uri`. + +### 6.2 Nonce +- Validate nonce existence and expiry at lookup time. +- Consume nonce on successful validation attempt (one-time use). +- Distinguish nonce-not-found, nonce-expired, and nonce-replayed errors. + +## 7. Message Type Handling Boundary +- Core feature validates and dispatches message type. +- For now, resource-link path must be complete here. +- Deep linking message validation/claim details are handled by `deep_linking` feature; core should still distinguish supported/unsupported message types and return deterministic errors. + +## 8. Error Taxonomy +Use structured error types for API compatibility and testability: +- `lightbulb/errors.CoreError` +- `lightbulb/errors.NonceError` + +String compatibility should be provided via conversion helpers: +- `lightbulb/errors.core_error_to_string` +- `lightbulb/errors.nonce_error_to_string` + +## 9. Runtime Flows + +### 9.1 OIDC Login Flow +1. Validate login request params. +2. Resolve registration. +3. Generate state + nonce. +4. Persist login context keyed by state. +5. Build redirect URL with required OIDC params. + +### 9.2 Launch Validation Flow +1. Validate `id_token` + `state` presence and state equality. +2. Peek issuer/audience and resolve registration. +3. Verify JWT signature with matching JWK. +4. Validate required launch claims and message type. +5. Validate deployment, timestamps, nonce. +6. Validate target-link consistency from stored login context. +7. Return claims. + +## 10. Testability Design + +### 10.1 Unit Tests +- claim decoder/validator behavior by claim and message type +- audience and `azp` resolution matrix +- timestamp skew boundaries +- nonce expiry/replay behavior + +### 10.2 Integration-Style Tests +- OIDC login -> launch roundtrip with persisted context +- registration/deployment binding and mismatch cases +- JWK selection by `kid` + +### 10.3 Certification-Oriented Negative Tests +- missing required claims +- wrong version claim +- malformed/missing `aud` or `azp` +- invalid signature / unknown `kid` +- state mismatch and target-link mismatch +- replayed/expired nonce + +## 11. File-Level Design Impact +- `src/lightbulb/tool.gleam` +- `src/lightbulb/providers/data_provider.gleam` +- `src/lightbulb/providers/memory_provider.gleam` +- `src/lightbulb/nonce.gleam` (if TTL helpers are introduced) +- `test/lightbulb/core_test.gleam` (new) +- `test/lightbulb/providers/memory_provider/*` (provider behavior expansion) + +## 12. Certification Traceability (Core) +Implementation and tests should explicitly map to Core tool-test objectives in the LTI certification guide, including: +- Core launch flow success scenarios. +- Required claim validation behavior. +- Known bad-payload rejection classes. +- State, nonce, signature, and audience validation behavior. diff --git a/docs/features/core/plan.md b/docs/features/core/plan.md new file mode 100644 index 0000000..48a33f2 --- /dev/null +++ b/docs/features/core/plan.md @@ -0,0 +1,97 @@ +# Implementation Plan: core + +## Phase 1: Validation Baseline and Claim Matrix +- [x] Add a core validation matrix in code comments/docs mapping each required claim to validator function. +- [x] Implement explicit required claim checks for `LtiResourceLinkRequest`: + - [x] message type claim + - [x] LTI version claim (`1.3.0`) + - [x] deployment_id + - [x] target_link_uri claim + - [x] resource_link.id + - [x] roles list +- [x] Normalize typed error variants for claim failures (`JwtInvalidClaim`, `MessageTypeUnsupported`, etc.). + +## Phase 2: Audience and Registration Resolution +- [x] Replace `aud` string-only decode path with `aud` string-or-list support. +- [x] Implement deterministic `aud`/`azp` algorithm for client_id resolution. +- [x] Validate resolved client_id is part of token audience context. +- [x] Preserve registration lookup behavior through `(issuer, client_id)` provider call. +- [x] Add audience matrix tests: + - [x] single-aud success + - [x] multi-aud with azp success + - [x] multi-aud without azp failure + - [x] azp not in aud failure + +## Phase 3: State Context and Target Link Consistency +- [x] Finalize provider extension strategy: + - [x] extend `DataProvider` directly, or + - [x] add dedicated state context provider and adapter +- [x] Implement login-context persistence at OIDC login: + - [x] state + - [x] target_link_uri + - [x] issuer + - [x] client_id + - [x] expiration timestamp +- [x] Implement login-context retrieval/consume at launch validation. +- [x] Enforce launch `target_link_uri` claim equality with stored login context. +- [x] Add state/context tests: + - [x] missing context + - [x] expired context + - [x] mismatched target_link_uri + +## Phase 4: Nonce Hardening and Time Validation +- [x] Update memory provider nonce validation to enforce expiration during validation, not only via cleanup. +- [x] Preserve nonce one-time-use semantics for replay prevention. +- [x] Add explicit nonce error mapping for not-found vs expired vs replay. +- [x] Review timestamp skew behavior and make skew configurable or increase default tolerance. +- [x] Add boundary tests for `exp`, `iat` (and `nbf` if added). + +## Phase 5: JWT/JWKS and HTTP Robustness +- [x] Normalize JWKS fetch `Accept` header to standard JSON expectations. +- [x] Harden JWK selection and decode error messages (missing `kid`, malformed keyset). +- [x] Add tests for unknown `kid` and malformed keyset payload behavior. + +## Phase 6: Core Test Suite Build-Out +- [x] Add new `test/lightbulb/core_test.gleam` with end-to-end launch validation tests. +- [x] Add fixtures/utilities for signed JWT generation covering success and failure classes. +- [x] Add test scenarios: + - [x] successful resource-link launch + - [x] missing required claim failures + - [x] invalid version failure + - [x] unsupported message type failure + - [x] audience and azp failures + - [x] deployment mismatch failure + - [x] state mismatch failure + - [x] target_link_uri mismatch failure + - [x] expired/replayed nonce failures + - [x] invalid signature / unknown kid failure + +## Phase 7: Certification Traceability and Docs +- [x] Map core test scenarios to Core tool-test objectives in the LTI certification guide. +- [x] Update `docs/complete_lti_support/conformance_matrix.md` with core mapping rows. +- [x] Update `README.md` launch flow docs for any provider/API additions. +- [x] Add migration notes for provider implementers if interfaces changed. + +## Cross-Feature Dependencies +- `oauth_provider`: provider contract evolution for login context/state persistence and nonce semantics (Phase 3/4 alignment). +- `deep_linking`: shared message-type routing in `src/lightbulb/tool.gleam`; core must expose deterministic unsupported/supported message handling. +- `certification`: core conformance tests and matrix mappings are required before certification-ready status. + +## File-Level Execution +- `src/lightbulb/tool.gleam` +- `src/lightbulb/providers/data_provider.gleam` +- `src/lightbulb/providers/memory_provider.gleam` +- `src/lightbulb/nonce.gleam` (optional) +- `test/lightbulb/core_test.gleam` (new) +- `test/lightbulb/providers/memory_provider/tables_test.gleam` (if provider persistence logic expands) +- `docs/complete_lti_support/conformance_matrix.md` +- `README.md` + +## Definition of Done +- [x] Core required claims validated for resource-link launch. +- [x] Audience/azp parsing and registration resolution are fully covered by tests. +- [x] State context + target-link consistency enforcement implemented. +- [x] Nonce and timestamp hardening implemented with boundary coverage. +- [x] Core end-to-end test suite exists and passes. +- [x] Certification mapping for core is documented. +- [x] Backward compatibility/migration notes are documented. diff --git a/docs/features/core/prd.md b/docs/features/core/prd.md new file mode 100644 index 0000000..c00e4a3 --- /dev/null +++ b/docs/features/core/prd.md @@ -0,0 +1,31 @@ +# Product Requirements Document: core + +## 1. Problem / User Value +Lightbulb must provide certification-grade LTI Core 1.3 launch handling. Current gaps in claim validation, audience handling, target link consistency, and nonce semantics can cause security and interoperability failures. + +## 2. Scope +### In scope +- Required claim validation and message-type-aware launch validation. +- `aud` / `azp` handling for registration resolution. +- Login state and `target_link_uri` consistency checks. +- Nonce hardening (expiration + one-time use). + +### Out of scope +- Deep Linking response generation. +- AGS/NRPS service APIs. + +## 3. Requirements in Scope +- FR-CORE-01 +- FR-CORE-02 +- FR-CORE-03 (Core handling aspects) +- FR-PROV-01 (Core launch semantics) +- NFR-01 +- NFR-02 +- NFR-03 +- NFR-04 + +## 4. Success Criteria +- Required Core claim checks implemented and tested. +- Launch validation supports valid audience forms. +- Target-link consistency and nonce semantics enforced. +- Core bad-payload conformance cases covered in tests. diff --git a/docs/features/deep_linking/fdd.md b/docs/features/deep_linking/fdd.md new file mode 100644 index 0000000..e2c6d84 --- /dev/null +++ b/docs/features/deep_linking/fdd.md @@ -0,0 +1,177 @@ +# Functional Design Document: deep_linking + +## 1. Normative References +- LTI Deep Linking 2.0: https://www.imsglobal.org/spec/lti-dl/v2p0/ +- LTI Core 1.3 (launch and shared claims): https://www.imsglobal.org/spec/lti/v1p3 +- LTI 1.3 certification guide (Deep Linking tool tests): https://www.imsglobal.org/spec/lti/v1p3/cert + +Key normative points used in this design: +- Deep linking request message type is `LtiDeepLinkingRequest`. +- `deep_linking_settings` claim is required and includes required fields: + - `deep_link_return_url` + - `accept_types` + - `accept_presentation_document_targets` +- Tool returns a Deep Linking response as a signed JWT, submitted via auto-post HTML form to `deep_link_return_url` using parameter name `JWT`. +- Deep linking response claim requirements include: + - `aud` = request `iss` + - `https://purl.imsglobal.org/spec/lti/claim/message_type = LtiDeepLinkingResponse` + - `https://purl.imsglobal.org/spec/lti/claim/version = 1.3.0` + - `https://purl.imsglobal.org/spec/lti/claim/deployment_id` + - `https://purl.imsglobal.org/spec/lti-dl/claim/data` required only if request carried settings `data` +- `content_items` may be omitted or empty to indicate no item created. + +## 2. Current Baseline and Gaps +Current codebase has no deep-linking-specific modules or API surface. + +Gaps: +- No message validator path for `LtiDeepLinkingRequest` in `src/lightbulb/tool.gleam`. +- No decoder for `deep_linking_settings` claim. +- No deep linking response JWT builder. +- No content item models/builders. +- No form-post helper for `JWT` return to platform. +- No deep linking tests. + +## 3. Target Public API + +### 3.1 Modules +- `src/lightbulb/deep_linking.gleam` +- `src/lightbulb/deep_linking/settings.gleam` +- `src/lightbulb/deep_linking/content_item.gleam` + +### 3.2 Types +- `DeepLinkingSettings` + - required: + - `deep_link_return_url: String` + - `accept_types: List(String)` + - `accept_presentation_document_targets: List(String)` + - optional: + - `accept_media_types: Option(String)` + - `accept_multiple: Option(Bool)` + - `auto_create: Option(Bool)` + - `title: Option(String)` + - `text: Option(String)` + - `data: Option(String)` + - `accept_lineitem: Option(Bool)` +- `DeepLinkingRequest` + - shared launch claims + typed `deep_linking_settings` +- `DeepLinkingResponse` + - required response claims and optional message/log/error fields +- `ContentItem` union + - `Link` + - `LtiResourceLink` + - initially optional support for `File`, `Html`, `Image` can be staged, but include model hooks now + +### 3.3 Builder APIs (additive) +- `get_deep_linking_settings(claims) -> Result(DeepLinkingSettings, DeepLinkingError)` +- `build_response_jwt(request_claims, settings, items, options, active_jwk) -> Result(String, DeepLinkingError)` +- `build_response_form_post(deep_link_return_url, jwt) -> Result(String, DeepLinkingError)` +- content item constructors: + - `content_item.link(...)` + - `content_item.lti_resource_link(...)` + - optional staged constructors for file/html/image + +## 4. Content Item and Validation Rules + +### 4.1 Type Acceptance +- Validate requested content item types against `settings.accept_types`. +- If `accept_multiple` is false, enforce max one returned item. +- If no items selected, allow empty response (`content_items` omitted or `[]`). + +### 4.2 LTI Resource Link Item +For `ltiResourceLink` type: +- include `type = "ltiResourceLink"` +- include `url` when explicit launch URL should be returned +- support optional `custom` map (string values only) +- support optional `lineItem` object + +### 4.3 Line Item in Deep Linking Item +- Include `lineItem` only when: + - platform supports it (`accept_lineitem == true`) or tool chooses permissive fallback +- `lineItem.scoreMaximum` required when lineItem included. +- Include optional `resourceId`, `tag`, `label`, `gradesReleased` as available. + +## 5. Response JWT Claims and Semantics +Required response claims: +- `aud`: request `iss` +- `https://purl.imsglobal.org/spec/lti/claim/message_type`: `LtiDeepLinkingResponse` +- `https://purl.imsglobal.org/spec/lti/claim/version`: `1.3.0` +- `https://purl.imsglobal.org/spec/lti/claim/deployment_id`: from request + +Conditional claims: +- `https://purl.imsglobal.org/spec/lti-dl/claim/data`: must echo request settings `data` when present + +Optional claims: +- `https://purl.imsglobal.org/spec/lti-dl/claim/content_items` +- `https://purl.imsglobal.org/spec/lti-dl/claim/msg` +- `https://purl.imsglobal.org/spec/lti-dl/claim/log` +- `https://purl.imsglobal.org/spec/lti-dl/claim/errormsg` +- `https://purl.imsglobal.org/spec/lti-dl/claim/errorlog` + +JWT signing behavior: +- Sign with RS256 and active tool JWK. +- Include `kid` in JWS header. +- Include standard token time claims (`iat`, `exp`) with short TTL. + +## 6. Core Integration Boundary +- `src/lightbulb/tool.gleam` message validation must recognize `LtiDeepLinkingRequest` and validate deep-linking-specific required claims. +- Core `validate_launch` should return claims that allow deep-linking feature to decode settings. +- Deep linking request handling should remain additive and not break resource-link launch flows. + +## 7. Error Taxonomy +Use structured Deep Linking errors for API testability and explicit handling: +- `lightbulb/errors.DeepLinkingError` + +String compatibility should be provided via conversion helper: +- `lightbulb/errors.deep_linking_error_to_string` + +## 8. Runtime Flows + +### 8.1 Request Handling +1. Receive validated launch claims from core path. +2. Verify message type is deep linking request. +3. Decode and validate `deep_linking_settings`. +4. Build UI context using settings constraints. + +### 8.2 Response Handling +1. Build content item list from user selection. +2. Validate item types against `accept_types` and multiplicity constraints. +3. Build response claims (including conditional `data`). +4. Sign JWT. +5. Return auto-submit form HTML posting `JWT` to `deep_link_return_url`. + +## 9. Testability Design + +### 9.1 Unit Tests +- settings decode required/optional fields +- response claim builder required/conditional claims +- content item validation (type acceptance, accept_multiple) +- form-post helper output contract + +### 9.2 Integration-Style Tests +- deep link launch claims -> settings decode -> response JWT -> form HTML flow +- JWT verification using active public key and claim assertions + +### 9.3 Certification-Oriented Tests +- response format is deep linking response +- response timestamps valid +- RS256 + valid signature + matching `kid` +- required claims present with correct values +- proof-oriented flow support (response absorption path in example app) + +## 10. File-Level Design Impact +- `src/lightbulb/tool.gleam` +- `src/lightbulb/deep_linking.gleam` (new) +- `src/lightbulb/deep_linking/settings.gleam` (new) +- `src/lightbulb/deep_linking/content_item.gleam` (new) +- `src/lightbulb/services/ags/line_item.gleam` (for shared lineItem shape reuse, optional) +- `test/lightbulb/deep_linking_test.gleam` (new) +- `test/lightbulb/core_test.gleam` (message-type route coverage) + +## 11. Certification Traceability (Deep Linking) +Implementation and tests should explicitly map to Deep Linking tool-test objectives in the certification guide section 6.2, including: +- request receipt and response submission +- response format validity +- timestamp validity +- signature validity +- required claims verification +- response value affirmation behavior diff --git a/docs/features/deep_linking/plan.md b/docs/features/deep_linking/plan.md new file mode 100644 index 0000000..838cb31 --- /dev/null +++ b/docs/features/deep_linking/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: deep_linking + +## Phase 1: Core Launch Integration and Settings Decoder + +- [x] Extend `src/lightbulb/tool.gleam` message validators to support `LtiDeepLinkingRequest`. +- [x] Add `DeepLinkingSettings` type and decoder in `src/lightbulb/deep_linking/settings.gleam`: + - [x] required fields (`deep_link_return_url`, `accept_types`, `accept_presentation_document_targets`) + - [x] optional fields (`accept_media_types`, `accept_multiple`, `auto_create`, `title`, `text`, `data`, `accept_lineitem`) +- [x] Add helper `get_deep_linking_settings(claims)` in `src/lightbulb/deep_linking.gleam`. +- [x] Add unit tests for settings decode success/failure classes. + +## Phase 2: Content Item Model and Validation Rules + +- [x] Add `ContentItem` union type and encoders in `src/lightbulb/deep_linking/content_item.gleam`. +- [x] Implement content item builders: + - [x] `link(...)` + - [x] `lti_resource_link(...)` +- [x] Implement deep-linking response item validator: + - [x] item type allowed by `accept_types` + - [x] multiplicity constrained by `accept_multiple` + - [x] lineItem payload allowed when `accept_lineitem` is true (or explicitly tolerated by policy) +- [x] Add tests for accepted/rejected item combinations. + +## Phase 3: Response JWT Builder + +- [x] Implement response claim builder in `src/lightbulb/deep_linking.gleam`. +- [x] Enforce required claims: + - [x] `aud = request iss` + - [x] message_type = `LtiDeepLinkingResponse` + - [x] version = `1.3.0` + - [x] deployment_id from request +- [x] Implement conditional `data` echo behavior. +- [x] Add optional response message/log/error claims support. +- [x] Implement JWT signing with active JWK (`kid`, RS256, `iat`, `exp`). +- [x] Add tests for signed token claims and signature verification. + +## Phase 4: Form-Post Response Helper + +- [x] Implement helper returning HTML form with: + - [x] `action = deep_link_return_url` + - [x] hidden input `name="JWT"` + - [x] auto-submit behavior +- [x] Validate/normalize return URL handling and add explicit failure for invalid URL. +- [x] Add form helper tests for contract and escaping behavior. + +## Phase 5: End-to-End Integration Paths + +- [x] Add integration tests in `test/lightbulb/deep_linking_test.gleam`: + - [x] launch claims -> settings decode -> item build -> response JWT + - [x] response JWT -> form-post payload +- [x] Add coverage in `test/lightbulb/core_test.gleam` for deep-link message routing. +- [x] Add optional example endpoint flow in README or example app for proof-mode runs. + +## Phase 6: Certification Coverage and Documentation + +- [x] Map tests to certification guide Deep Linking section 6.2 objectives: + - [x] `6.2.1` request/response flow + - [x] `6.2.2` response format + - [x] `6.2.3` response timestamps + - [x] `6.2.4` response signature + - [x] `6.2.5` required claims + - [x] `6.2.6` response value affirmation support +- [x] Update `docs/complete_lti_support/conformance_matrix.md` with deep_linking rows. +- [x] Update `README.md` with deep linking usage example. + +## Cross-Feature Dependencies + +- `core`: deep-link request recognition and launch message-type validation is routed through core `validate_launch`. +- `oauth_provider`: active key management and signing dependencies are shared with token/assertion infrastructure. +- `ags`: optional `lineItem` payload support in deep-link content items should reuse AGS line item model decisions when available. +- `certification`: Deep Linking cert objective mappings (`6.2.x`) must be represented in conformance matrix and evidence package. + +## File-Level Execution + +- `src/lightbulb/tool.gleam` +- `src/lightbulb/deep_linking.gleam` (new) +- `src/lightbulb/deep_linking/settings.gleam` (new) +- `src/lightbulb/deep_linking/content_item.gleam` (new) +- `test/lightbulb/deep_linking_test.gleam` (new) +- `test/lightbulb/core_test.gleam` (new or expanded) +- `docs/complete_lti_support/conformance_matrix.md` +- `README.md` + +## Definition of Done + +- [x] Deep-link request settings are decoded and validated from launch claims. +- [x] Content item builders and validation constraints are implemented. +- [x] Signed deep-link response JWT builder exists with required/conditional claims. +- [x] Form-post helper emits valid `JWT` submission payload. +- [x] Deep linking integration tests pass. +- [x] Certification mapping and docs are updated. diff --git a/docs/features/deep_linking/prd.md b/docs/features/deep_linking/prd.md new file mode 100644 index 0000000..7b9f760 --- /dev/null +++ b/docs/features/deep_linking/prd.md @@ -0,0 +1,26 @@ +# Product Requirements Document: deep_linking + +## 1. Problem / User Value +Lightbulb needs full Deep Linking 2.0 support so tools can accept deep-link launches and return standards-compliant content selections. + +## 2. Scope +### In scope +- `LtiDeepLinkingRequest` claim/model support. +- Deep-link response JWT generation. +- Content-item builders and form-post helper. + +### Out of scope +- Core audience/provider semantics beyond deep-link integration points. + +## 3. Requirements in Scope +- FR-CORE-03 (deep-linking message type support) +- FR-DL-01 +- FR-DL-02 +- NFR-01 +- NFR-02 +- NFR-04 + +## 4. Success Criteria +- Typed deep-link request settings API available. +- Valid signed deep-link response JWTs and POST helper available. +- Conformance-style deep-link tests pass. diff --git a/docs/features/nrps/fdd.md b/docs/features/nrps/fdd.md new file mode 100644 index 0000000..5b89280 --- /dev/null +++ b/docs/features/nrps/fdd.md @@ -0,0 +1,203 @@ +# Functional Design Document: nrps + +## 1. Normative References +- NRPS 2.0 spec: https://www.imsglobal.org/spec/lti-nrps/v2p0/ +- LTI 1.3 certification guide (NRPS tool tests): https://www.imsglobal.org/spec/lti/v1p3/cert + +Key normative points used in this design: +- NRPS claim URL: `https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice` +- Required claim fields include: + - `context_memberships_url` + - `service_versions` +- Scope for roster read: `https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly` +- Membership container returns `members` array. +- Pagination and delta patterns use HTTP `Link` headers (including `next` and `differences` relations). +- Role and resource-link scoping are represented via query parameters (including `role`, `limit`, `rlid`). + +## 2. Current Baseline and Gaps +Current code (`src/lightbulb/services/nrps.gleam`) implements: +- `fetch_memberships(http_provider, context_memberships_url, access_token)` +- `nrps_available(claims)` +- `get_membership_service_url(claims)` + +Current gaps: +- NRPS claim decoder currently requires non-normative fields (`errors`, `validation_context`) and ignores `service_versions`. +- Membership decoder currently requires many fields that are optional in practice; this causes decode failures for valid minimal payloads. +- No options API for role filter, limit, resource-link scoping. +- No pagination or differences-link support. +- No typed result model for paging metadata. +- `fetch_memberships` hardcodes `?limit=1000`, which is inflexible. + +## 3. Target Public API + +### 3.1 Constants +Keep existing: +- `nrps_claim_url` +- `context_membership_readonly_claim_url` + +### 3.2 Types +- `NrpsClaim` + - `context_memberships_url: String` + - `service_versions: List(String)` +- `Membership` (tolerant model) + - required: + - `user_id: String` + - `roles: List(String)` + - optional: + - `status: Option(String)` + - `name: Option(String)` + - `given_name: Option(String)` + - `family_name: Option(String)` + - `middle_name: Option(String)` + - `email: Option(String)` + - `picture: Option(String)` + - `lis_person_sourcedid: Option(String)` +- `MembershipsQuery` + - `role: Option(String)` + - `limit: Option(Int)` + - `rlid: Option(String)` + - `url: Option(String)` (direct next/differences URL override for paging workflows) +- `PageLinks` + - `next: Option(String)` + - `differences: Option(String)` + - `prev: Option(String)` + - `first: Option(String)` + - `last: Option(String)` +- `MembershipsPage` + - `members: List(Membership)` + - `links: PageLinks` + +### 3.3 Operations +Additive API surface in `src/lightbulb/services/nrps.gleam`: +- `get_nrps_claim(claims) -> Result(NrpsClaim, NrpsError)` (make public) +- `fetch_memberships_with_options(http_provider, context_memberships_url, query, access_token) -> Result(MembershipsPage, NrpsError)` +- `fetch_next_memberships_page(http_provider, next_url, access_token) -> Result(MembershipsPage, NrpsError)` +- `fetch_differences_memberships_page(http_provider, differences_url, access_token) -> Result(MembershipsPage, NrpsError)` + +Compatibility: +- Keep `fetch_memberships(...)` as wrapper around options API with default behavior, but + return `Result(List(Membership), NrpsError)`. + +## 4. Claim and Scope Validation Rules + +### 4.1 NRPS Claim Decode Rules +- Required: + - `context_memberships_url` string + - `service_versions` list of strings +- Ignore unknown extra fields. +- Do not require non-normative fields. + +### 4.2 Scope Availability +Add helper predicate: +- `can_read_memberships(claims) -> Bool` + +Behavior: +- Returns true only when NRPS claim exists and scope list includes `contextmembership.readonly`. + +## 5. Membership Decode Rules +- Required fields for each member: + - `user_id` + - `roles` +- All other fields optional. +- If `status` missing, preserve as `None` or optionally map default in helper layer (do not force decoder failure). +- Decoder must tolerate additional unknown member fields. + +## 6. HTTP Contract and Query Behavior + +### 6.1 Headers +- Request: + - `Authorization: Bearer ` + - `Accept: application/vnd.ims.lti-nrps.v2.membershipcontainer+json` +- `Content-Type` is not required for GET and should not be mandatory. + +### 6.2 Request URL Construction +Base URL path: +- `context_memberships_url` + +Supported query params: +- `role` +- `limit` +- `rlid` + +If `query.url` is provided (next/differences continuation), use that URL directly and ignore base/query construction. + +### 6.3 Response Handling +- Success status: `200` (tolerate `201` for robustness) +- Decode body as membership container. +- Parse `Link` headers into `PageLinks`. +- If link header parsing fails, still return decoded members with empty links and log warning. + +## 7. Paging and Differences Design +- Reuse shared `src/lightbulb/http/link_header.gleam` parser. +- Parse and expose link relations used by NRPS workflows: + - `next` + - `differences` + - optionally `prev`, `first`, `last` +- Provide convenience methods for consuming continuation links. + +## 8. Error Taxonomy +Use explicit NRPS error types for public APIs (for example `NrpsError`) rather than +string identifiers. + +Recommended variants: +- `ClaimMissing` +- `ClaimInvalid` +- `ScopeInsufficient` +- `RequestInvalidUrl` +- `HttpUnexpectedStatus` +- `DecodeMembershipContainer` +- `DecodeMember` +- `PaginationInvalidLinkHeader` + +If string output is needed for logging/UI compatibility, provide a dedicated conversion +function (for example `nrps_error_to_string`). + +## 9. Runtime Flows + +### 9.1 Launch Claims to NRPS Availability +1. Decode NRPS claim. +2. Validate read scope presence. +3. Extract membership service URL. + +### 9.2 Membership Fetch with Options +1. Build URL from base + query options, unless continuation URL is explicitly provided. +2. Issue authenticated GET request. +3. Decode members and parse link headers. +4. Return `MembershipsPage`. + +### 9.3 Compatibility Wrapper +1. `fetch_memberships` calls `fetch_memberships_with_options` with default query. +2. Returns only `members` list, discarding paging metadata. + +## 10. Testability Design + +### 10.1 Unit Tests +- claim decode for required fields and unknown-field tolerance +- membership decode for minimal and expanded payloads +- query builder behavior (`role`, `limit`, `rlid`) +- link-header parsing for `next` and `differences` + +### 10.2 Integration-Style Tests +- one-page roster flow with optional fields +- multi-page flow following `next` +- differences flow following `differences` + +### 10.3 Negative Tests +- missing claim fields +- malformed member object (`roles` wrong type, missing `user_id`) +- invalid URL and unexpected status +- malformed Link header + +## 11. File-Level Design Impact +- `src/lightbulb/services/nrps.gleam` +- `src/lightbulb/services/nrps/membership.gleam` +- `src/lightbulb/http/link_header.gleam` (reuse/add if created by AGS) +- `test/lightbulb/services/nrps_test.gleam` +- `test/lightbulb/services/nrps_paging_test.gleam` (new) + +## 12. Certification Traceability (NRPS) +Implementation and tests should explicitly map to NRPS tool-test objectives in the certification guide section 5.3, including: +- NRPS claim presence and claim URL handling +- scope handling for access-token request and use +- membership retrieval behavior +- role-specific membership retrieval behavior diff --git a/docs/features/nrps/plan.md b/docs/features/nrps/plan.md new file mode 100644 index 0000000..1904c86 --- /dev/null +++ b/docs/features/nrps/plan.md @@ -0,0 +1,87 @@ +# Implementation Plan: nrps + +## Phase 1: Claim Model Correction and Public API +- [x] Update NRPS claim type/decoder in `src/lightbulb/services/nrps.gleam`: + - [x] require `context_memberships_url` + - [x] require `service_versions` + - [x] ignore unknown extra fields +- [x] Remove dependency on non-normative claim fields (`errors`, `validation_context`). +- [x] Expose `get_nrps_claim` as public helper. +- [x] Add claim decode unit tests for valid, invalid, and minimal payloads. + +## Phase 2: Membership Model Tolerance +- [x] Redesign `Membership` type in `src/lightbulb/services/nrps/membership.gleam` with required minimum fields (`user_id`, `roles`) and optional profile fields. +- [x] Update membership decoder for tolerant optional-field behavior. +- [x] Update existing tests and call sites for new type shape. +- [x] Add explicit tests for: + - [x] minimal member decode + - [x] expanded member decode + - [x] missing required keys failure + +## Phase 3: Options-Based Fetch API +- [x] Add `MembershipsQuery` type (`role`, `limit`, `rlid`, optional direct `url`). +- [x] Implement `fetch_memberships_with_options(...) -> Result(MembershipsPage, NrpsError)`. +- [x] Keep `fetch_memberships(...)` wrapper for compatibility. +- [x] Update request construction logic: + - [x] no hardcoded `limit=1000` + - [x] optional `role` + - [x] optional `limit` + - [x] optional `rlid` + +## Phase 4: Paging and Differences Support +- [x] Reuse/add `src/lightbulb/http/link_header.gleam` parser. +- [x] Add NRPS page link extraction (`next`, `differences`, optional `prev/first/last`). +- [x] Implement convenience functions: + - [x] `fetch_next_memberships_page(...)` + - [x] `fetch_differences_memberships_page(...)` +- [x] Ensure graceful fallback when link headers are malformed. + +## Phase 5: Scope and Availability Semantics +- [x] Add/adjust scope helper `can_read_memberships(claims)`. +- [x] Ensure `nrps_available` behavior aligns with claim + scope availability semantics. +- [x] Add failure paths for insufficient scope with explicit typed errors (for example `ScopeInsufficient`). + +## Phase 6: Test Matrix and Conformance Coverage +- [x] Expand `test/lightbulb/services/nrps_test.gleam` with: + - [x] options query serialization checks + - [x] scope availability checks + - [x] tolerant member decode scenarios +- [x] Add new paging-focused tests in `test/lightbulb/services/nrps_paging_test.gleam`: + - [x] next link continuation flow + - [x] differences link continuation flow + - [x] malformed link header fallback behavior +- [x] Add certification-targeted tests aligned to NRPS section 5.3: + - [x] `5.3.1` claim URL handling + - [x] `5.3.2` scope request behavior + - [x] `5.3.3` access token handling for NRPS calls + - [x] `5.3.4` members retrieval behavior + - [x] `5.3.5` role-specific members retrieval behavior +- [x] Map all NRPS tests in `docs/complete_lti_support/conformance_matrix.md`. + +## Phase 7: Documentation and Migration +- [x] Update README NRPS examples to include options API and paging usage. +- [x] Document compatibility wrapper behavior for `fetch_memberships`. +- [x] Add migration notes for `Membership` type shape changes. + +## Cross-Feature Dependencies +- `core`: NRPS claim availability depends on launch claim parsing and validated launch context. +- `oauth_provider`: NRPS access-token scope request and retrieval behavior depends on OAuth/client-assertion path. +- `ags`: shared Link-header parser should remain consistent across AGS and NRPS pagination usage. +- `certification`: NRPS objective mappings (`5.3.x`) and evidence links must be maintained in conformance artifacts. + +## File-Level Execution +- `src/lightbulb/services/nrps.gleam` +- `src/lightbulb/services/nrps/membership.gleam` +- `src/lightbulb/http/link_header.gleam` +- `test/lightbulb/services/nrps_test.gleam` +- `test/lightbulb/services/nrps_paging_test.gleam` (new) +- `docs/complete_lti_support/conformance_matrix.md` +- `README.md` + +## Definition of Done +- [x] NRPS claim decode is spec-aligned and tolerant. +- [x] Membership decode supports minimal valid payloads. +- [x] Options-based API and compatibility wrapper both work. +- [x] Paging/differences links are surfaced and consumable. +- [x] Scope/availability semantics are explicit and tested. +- [x] Certification mapping and docs are updated. diff --git a/docs/features/nrps/prd.md b/docs/features/nrps/prd.md new file mode 100644 index 0000000..eb25b12 --- /dev/null +++ b/docs/features/nrps/prd.md @@ -0,0 +1,25 @@ +# Product Requirements Document: nrps + +## 1. Problem / User Value +NRPS support must handle spec-correct claim decoding, tolerant member parsing, and practical paging/filter workflows for real rosters. + +## 2. Scope +### In scope +- NRPS claim model correction and tolerant decode behavior. +- Membership model updates for optional fields and defaults. +- Membership fetch options: role filter, limit, paging, differences, `rlid` support. + +### Out of scope +- AGS and deep-linking features. + +## 3. Requirements in Scope +- FR-NRPS-01 +- FR-NRPS-02 +- NFR-02 +- NFR-04 +- NFR-05 + +## 4. Success Criteria +- NRPS claims decode correctly across platform variants. +- Membership decode works with minimal valid payloads. +- Paging/filter/differences flows covered with tests. diff --git a/docs/features/oauth_provider/fdd.md b/docs/features/oauth_provider/fdd.md new file mode 100644 index 0000000..e23df32 --- /dev/null +++ b/docs/features/oauth_provider/fdd.md @@ -0,0 +1,241 @@ +# Functional Design Document: oauth_provider + +## 1. Normative References + +- 1EdTech Security Framework v1.1: https://www.imsglobal.org/spec/security/v1p1/ +- OAuth 2.0 (RFC 6749): https://datatracker.ietf.org/doc/html/rfc6749 +- JWT Profile for OAuth 2.0 Client Authentication (RFC 7523): https://datatracker.ietf.org/doc/html/rfc7523 +- LTI 1.3 certification guide: https://www.imsglobal.org/spec/lti/v1p3/cert + +Key normative points used in this design: + +- Access-token request for LTI services uses OAuth client-credentials with JWT client assertion (`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`). +- Assertion JWT claims must include `iss`, `sub`, `aud`, `iat`, `exp`, `jti`. +- Security framework indicates `aud` should identify the authorization server; when OpenID metadata provides `authorization_server`, that value should be used, else `token_endpoint`. +- OAuth token success response fields: `access_token` and `token_type` required; `expires_in` recommended; `scope` optional if unchanged. +- OAuth error response fields include `error` and optional `error_description`, `error_uri`. + +## 2. Current Baseline and Gaps + +Current code (`src/lightbulb/services/access_token.gleam`) implements: + +- JWT client assertion generation using active JWK. +- Token request with client credentials and `scope`. +- Access-token decode requiring `access_token`, `token_type`, `expires_in`, and `scope`. + +Current gaps: + +- Token decode is too strict: `scope` and `expires_in` handling should be more tolerant. +- OAuth error body is ignored; all failures collapse to generic error text. +- No typed error surface for token failures. +- No token caching; repeated calls always hit token endpoint. +- Provider contracts currently do not include state/login-context persistence hooks needed by core feature. +- Token assertion audience source is currently always token endpoint; no explicit support for discovered `authorization_server` audience. + +## 3. Target Public API + +### 3.1 Access Token Types + +- Keep `AccessToken` type, but evolve for tolerance: + - `token: String` + - `token_type: String` + - `expires_in: Option(Int)` (or preserve Int with fallback default) + - `scope: Option(String)` (or preserve String with fallback empty/requested scope) + +Compatibility direction: + +- Prefer explicit typed errors for public APIs: + - `fetch_access_token(...) -> Result(AccessToken, AccessTokenError)` + +### 3.2 Error Types + +Add typed error representation in `src/lightbulb/services/access_token.gleam`: + +- `AccessTokenError` + - `RequestBuildError(reason: String)` + - `HttpTransportError(reason: String)` + - `HttpStatusError(status: Int, body: String)` + - `OAuthError(error: String, error_description: Option(String), error_uri: Option(String))` + - `DecodeError(reason: String)` + - `AssertionBuildError(reason: String)` + +Provide a dedicated conversion helper when string output is required by logs/UI. + +### 3.3 Provider Contract Evolution + +This feature owns provider-side contract changes that support core launch correctness: + +- state/login-context store and retrieval +- nonce validation semantics with explicit expiration behavior + +Preferred approach: + +- add a dedicated provider record for session/login context (e.g., `LaunchContextProvider`) to minimize `DataProvider` churn +- provide adapter constructors to preserve current call pattern for existing users + +### 3.4 Optional Token Cache API + +Add optional cache utility layer (not mandatory in core request path): + +- `TokenCacheKey(issuer, client_id, scopes_hash)` +- `CachedToken(token: AccessToken, expires_at_unix: Int)` +- operations: + - `get(key) -> Option(CachedToken)` + - `put(key, token)` + - `invalidate(key)` + +Cache behavior: + +- treat token as stale when expiry within configurable refresh window (e.g., 60s) +- fallback to fresh fetch on cache miss/stale token + +## 4. Request and Assertion Design + +### 4.1 Token Request + +POST form-urlencoded fields: + +- `grant_type=client_credentials` +- `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` +- `client_assertion=` +- `scope=` + +Headers: + +- `Content-Type: application/x-www-form-urlencoded` +- `Accept: application/json` + +### 4.2 Client Assertion JWT + +Claims: + +- `iss = client_id` +- `sub = client_id` +- `aud = authorization_server or token_endpoint` +- `iat = now` +- `exp = now + short lifetime` +- `jti = unique ID` + +JWS header: + +- `alg = RS256` +- `typ = JWT` +- `kid = active_jwk.kid` + +Hardening: + +- use short assertion TTL (e.g., 5 min) rather than current 1 hour +- keep configurable to handle platform skew while minimizing replay risk + +## 5. Response Decode and Error Mapping + +### 5.1 Success Response + +Decode at minimum: + +- `access_token` (required) +- `token_type` (required) + +Tolerate absent: + +- `expires_in` +- `scope` + +Fallback behavior: + +- if `scope` missing, preserve requested scopes in local token model when needed +- if `expires_in` missing, mark unknown and avoid cache persistence by default + +### 5.2 Error Response + +On non-2xx responses, parse body as OAuth error shape if possible: + +- `error` +- `error_description` +- `error_uri` + +If parse fails, return `HttpStatusError` with raw body. + +## 6. Runtime Flows + +### 6.1 Token Retrieval Flow + +1. Build assertion JWT from registration and active JWK. +2. Send token request. +3. On success, decode tolerant token response. +4. On failure, parse OAuth error shape before generic fallback. +5. Return typed result. + +### 6.2 Token Cache Flow (Optional) + +1. Build cache key from issuer/client/scopes. +2. Read cache and validate staleness. +3. On hit, return cached token. +4. On miss/stale, fetch token and update cache when expiry is known. + +### 6.3 Provider Evolution Flow + +1. Add provider extensions/adapters. +2. Update memory provider implementation. +3. Update core launch flow to use context provider semantics. + +## 7. Error Taxonomy + +Use explicit typed errors (`AccessTokenError` and related provider/context errors) +rather than dot-separated string identifiers. + +Recommended `AccessTokenError` variants: + +- `RequestInvalidUrl` +- `AssertionBuildFailed` +- `HttpTransportError` +- `HttpUnexpectedStatus` +- `OAuthInvalidClient` +- `OAuthInvalidScope` +- `DecodeInvalidTokenResponse` +- `ProviderContextMissing` +- `ProviderContextExpired` + +If string output is needed for logging/UI compatibility, provide conversion helpers +(for example `access_token_error_to_string`). + +## 8. Testability Design + +### 8.1 Unit Tests + +- assertion claim construction and audience selection +- tolerant token decode variants (with/without scope/expires_in) +- OAuth error body decode and mapping +- cache keying/staleness behavior + +### 8.2 Integration-Style Tests + +- token endpoint request construction validation via `http_mock_provider` +- provider adapter behavior for context persistence and retrieval + +### 8.3 Negative Tests + +- malformed JSON success body +- non-JSON error body +- invalid/missing assertion inputs +- transport errors and non-2xx status behavior + +## 9. File-Level Design Impact + +- `src/lightbulb/services/access_token.gleam` +- `src/lightbulb/providers/data_provider.gleam` (or new provider module for context) +- `src/lightbulb/providers/memory_provider.gleam` +- `src/lightbulb/providers.gleam` (if provider aggregate expands) +- `src/lightbulb/tool.gleam` (integration with context provider) +- `test/lightbulb/services/access_token_test.gleam` +- `test/lightbulb/providers/memory_provider/*` +- `test/lightbulb/oauth_provider_test.gleam` (new, optional) + +## 10. Certification Traceability (OAuth/Provider) + +This feature underpins certification checks that require successful service access-token handling in AGS/NRPS and robust core launch state/nonce semantics: + +- access token request/response behavior +- scope handling +- provider-backed state/nonce correctness used by core validation + All related tests should map into certification rows from Core, AGS, and NRPS sections in `conformance_matrix.md`. diff --git a/docs/features/oauth_provider/plan.md b/docs/features/oauth_provider/plan.md new file mode 100644 index 0000000..9e2a008 --- /dev/null +++ b/docs/features/oauth_provider/plan.md @@ -0,0 +1,83 @@ +# Implementation Plan: oauth_provider + +## Phase 1: Token Response Tolerance and Typed Errors +- [x] Add typed token error model (`AccessTokenError`) in `src/lightbulb/services/access_token.gleam`. +- [x] Add tolerant token success decoder: + - [x] required: `access_token`, `token_type` + - [x] optional: `expires_in`, `scope` +- [x] Add OAuth error decoder for non-2xx responses: + - [x] `error` + - [x] `error_description` + - [x] `error_uri` +- [x] Expose typed token errors as the primary API contract. +- [x] Expand tests in `test/lightbulb/services/access_token_test.gleam` for tolerant and error variants. + +## Phase 2: Assertion Audience and Lifetime Hardening +- [x] Add explicit audience source selection strategy: + - [x] use discovered/registered authorization server when present + - [x] fallback to token endpoint +- [x] Shorten client assertion lifetime to safer default (configurable). +- [x] Add tests for assertion claims (`iss`, `sub`, `aud`, `iat`, `exp`, `jti`, `kid`). +- [x] Ensure errors for assertion generation failures are explicit and categorized. + +## Phase 3: Provider Contract Evolution for Core Integration +- [x] Finalize approach: + - [x] extend `DataProvider`, or + - [x] add new `LaunchContextProvider` and compose it +- [x] Implement provider-side context persistence/retrieval semantics in memory provider. +- [x] Add adapter constructors to preserve existing external provider integrations where possible. +- [x] Wire updated provider usage into core launch flow in `src/lightbulb/tool.gleam`. +- [x] Add provider migration notes for custom provider implementers. + +## Phase 4: Optional Token Cache Utility +- [x] Decide inclusion: built-in path vs opt-in helper module. +- [x] If included, add cache types and interface: + - [x] key by issuer/client/scope set + - [x] staleness window before expiry +- [x] Add fetch-with-cache helper that wraps `fetch_access_token`. +- [x] Add unit tests for cache hit/miss/stale/refresh behavior. + +## Phase 5: Test Matrix and Conformance Mapping +- [x] Add/expand integration tests for: + - [x] token request body and headers + - [x] non-2xx OAuth error response mapping + - [x] provider context semantics required by core +- [x] Map tests to certification-relevant rows in: + - [x] Core section mappings + - [x] AGS section mappings + - [x] NRPS section mappings +- [x] Update `docs/complete_lti_support/conformance_matrix.md` with oauth/provider coverage links. + +## Phase 6: Documentation and Rollout +- [x] Update README/service docs for: + - [x] typed token error behavior + - [x] optional cache utility usage + - [x] provider contract changes/adapters +- [x] Provide migration guide snippets for users with custom providers. +- [x] Document typed-only token API migration for `v2.0.0`. + +## Cross-Feature Dependencies +- `core`: provider contract changes for launch context/state/nonce are consumed directly by core launch validation flow. +- `ags`: token request scope behavior must support AGS objective coverage (`5.4.x`). +- `nrps`: token request scope behavior must support NRPS objective coverage (`5.3.x`). +- `deep_linking`: key management/signing behavior should stay consistent with deep-link response JWT signing needs. +- `certification`: OAuth/provider conformance checks must be linked to matrix rows spanning core/ags/nrps. + +## File-Level Execution +- `src/lightbulb/services/access_token.gleam` +- `src/lightbulb/providers/data_provider.gleam` +- `src/lightbulb/providers/memory_provider.gleam` +- `src/lightbulb/providers.gleam` +- `src/lightbulb/tool.gleam` +- `test/lightbulb/services/access_token_test.gleam` +- `test/lightbulb/providers/memory_provider/*` +- `docs/complete_lti_support/conformance_matrix.md` +- `README.md` + +## Definition of Done +- [x] Token decode is tolerant and standards-aligned. +- [x] OAuth error responses are parsed and surfaced with deterministic categories. +- [x] Assertion audience and TTL behavior are hardened and tested. +- [x] Provider evolution for core context semantics is implemented with migration path. +- [x] Optional token cache decision is implemented or explicitly deferred with rationale. +- [x] Certification mappings and docs are updated. diff --git a/docs/features/oauth_provider/prd.md b/docs/features/oauth_provider/prd.md new file mode 100644 index 0000000..00f14d2 --- /dev/null +++ b/docs/features/oauth_provider/prd.md @@ -0,0 +1,25 @@ +# Product Requirements Document: oauth_provider + +## 1. Problem / User Value +Production interoperability requires tolerant OAuth token handling and provider contracts that support secure state/nonce semantics. + +## 2. Scope +### In scope +- OAuth token decode tolerance and structured error mapping. +- Provider interface updates for state/context and nonce semantics. +- Optional token caching utility decision and implementation. + +### Out of scope +- Protocol-specific AGS/NRPS business logic. + +## 3. Requirements in Scope +- FR-PROV-01 +- NFR-01 +- NFR-02 +- NFR-03 +- NFR-04 + +## 4. Success Criteria +- Token path handles common platform response variations. +- Provider contract supports secure launch-state/nonce workflows. +- Migration approach for provider implementers is documented. diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..e69de29 diff --git a/gleam.toml b/gleam.toml index 144ac8b..4882c20 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "lightbulb" -version = "1.0.1" +version = "2.0.0" # Fill out these fields if you intend to generate HTML documentation or publish # your project to the Hex package manager. @@ -19,7 +19,6 @@ repository = { type = "github", user = "Simon-Initiative", repo = "lightbulb" } # Items in this list are "globs" that are matched against module names. See: # https://docs.rs/glob/latest/glob/struct.Pattern.html internal_modules = [ - "lightbulb/jwk", "lightbulb/utils/**", "lightbulb/providers/http_mock_provider", "lightbulb/providers/httpc_provider", @@ -29,7 +28,6 @@ internal_modules = [ [dependencies] gleam_stdlib = ">= 0.60.0 and < 2.0.0" -birl = ">= 1.8.0 and < 2.0.0" gleam_http = ">= 4.0.0 and < 5.0.0" gleam_httpc = ">= 5.0.0 and < 6.0.0" gleam_json = ">= 3.0.1 and < 4.0.0" @@ -39,6 +37,7 @@ gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_erlang = ">= 1.3.0 and < 2.0.0" gleam_time = ">= 1.7.0 and < 2.0.0" youid = ">= 1.5.1 and < 2.0.0" +logging = ">= 1.3.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index d24e9c8..c0932c2 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,26 +2,22 @@ # You typically do not need to edit this file packages = [ - { name = "birl", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "gleam_time", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "9924A2C3EECD33A5C94CD6859570261F2620435C54DAC5F2B00E5E6C806EEC6F" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, - { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, { name = "jose", version = "1.11.12", build_tools = ["mix", "rebar3"], requirements = [], otp_app = "jose", source = "hex", outer_checksum = "31E92B653E9210B696765CDD885437457DE1ADD2A9011D92F8CF63E4641BAB7B" }, { name = "jsx", version = "3.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "jsx", source = "hex", outer_checksum = "0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3" }, - { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] [requirements] -birl = { version = ">= 1.8.0 and < 2.0.0" } gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } @@ -32,4 +28,5 @@ gleam_time = { version = ">= 1.7.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } jose = { version = ">= 1.11.10 and < 2.0.0" } jsx = { version = ">= 3.1.0 and < 4.0.0" } +logging = { version = ">= 1.3.0 and < 2.0.0" } youid = { version = ">= 1.5.1 and < 2.0.0" } diff --git a/scripts/generate_evidence_summary.sh b/scripts/generate_evidence_summary.sh new file mode 100755 index 0000000..e8ae642 --- /dev/null +++ b/scripts/generate_evidence_summary.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="docs/complete_lti_support/evidence" +OUT="${ROOT}/summary.md" + +[[ -d "$ROOT" ]] || { + echo "evidence root not found: $ROOT" >&2 + exit 1 +} + +{ + echo "# Evidence Summary" + echo + echo "Generated at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo + echo "## Evidence Index" + + find "$ROOT" -type f -name '*.md' \ + ! -path "$OUT" \ + ! -path "$ROOT/README.md" \ + | sort \ + | while read -r file; do + rel="${file#${ROOT}/}" + echo "- ${rel}" + done +} > "$OUT" + +echo "updated ${OUT}" diff --git a/scripts/lint_conformance_matrix.sh b/scripts/lint_conformance_matrix.sh new file mode 100755 index 0000000..2e9b887 --- /dev/null +++ b/scripts/lint_conformance_matrix.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +MATRIX_PATH="${1:-docs/complete_lti_support/conformance_matrix.md}" +EPIC_PATH="docs/complete_lti_support/epic_prd.md" + +required_columns=( + "requirement_id" + "spec_reference" + "cert_reference" + "feature_slug" + "implementation_refs" + "test_refs" + "status" + "owner" + "last_verified_date" + "evidence_refs" +) + +allowed_statuses=("not_started" "in_progress" "implemented" "verified") +allowed_feature_slugs=("core" "deep_linking" "ags" "nrps" "oauth_provider" "certification") + +trim() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + printf '%s' "$value" +} + +contains() { + local needle="$1" + shift + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +fail() { + echo "matrix lint failed: $1" >&2 + exit 1 +} + +extract_cells() { + local line="$1" + local -n out_ref=$2 + out_ref=() + + local trimmed_line + trimmed_line="$(trim "$line")" + [[ -z "$trimmed_line" ]] && return + [[ "${trimmed_line:0:1}" != "|" ]] && return + + local content="${trimmed_line#|}" + content="${content%|}" + + local IFS='|' + local parts=() + read -r -a parts <<< "$content" + + local part + for part in "${parts[@]}"; do + out_ref+=("$(trim "$part")") + done +} + +validate_ref_list() { + local label="$1" + local refs="$2" + + local IFS=';' + local entries=() + read -r -a entries <<< "$refs" + + local entry + for entry in "${entries[@]}"; do + local ref + ref="$(trim "$entry")" + [[ -z "$ref" || "$ref" == "-" ]] && continue + + local path="$ref" + path="${path%%::*}" + path="${path%%#*}" + path="${path%%:*}" + + if [[ ! -e "$path" ]]; then + fail "missing path in ${label}: ${path} (from '${ref}')" + fi + done +} + +[[ -f "$MATRIX_PATH" ]] || fail "matrix file not found: $MATRIX_PATH" +[[ -f "$EPIC_PATH" ]] || fail "epic requirements file not found: $EPIC_PATH" + +header_line_number="$(grep -n '^| requirement_id |' "$MATRIX_PATH" | head -n 1 | cut -d: -f1)" +[[ -n "$header_line_number" ]] || fail "table header starting with 'requirement_id' not found" + +header_line="$(sed -n "${header_line_number}p" "$MATRIX_PATH")" +separator_line="$(sed -n "$((header_line_number + 1))p" "$MATRIX_PATH")" + +[[ "$separator_line" =~ ^\|[[:space:]-]+\| ]] || fail "table separator row missing after header" + +header_cells=() +extract_cells "$header_line" header_cells + +for col in "${required_columns[@]}"; do + contains "$col" "${header_cells[@]}" || fail "required column missing: $col" +done + +get_column_index() { + local col_name="$1" + local idx=0 + for cell in "${header_cells[@]}"; do + if [[ "$cell" == "$col_name" ]]; then + echo "$idx" + return + fi + idx=$((idx + 1)) + done + fail "column index lookup failed for: $col_name" +} + +requirement_idx="$(get_column_index "requirement_id")" +feature_slug_idx="$(get_column_index "feature_slug")" +implementation_refs_idx="$(get_column_index "implementation_refs")" +test_refs_idx="$(get_column_index "test_refs")" +status_idx="$(get_column_index "status")" +last_verified_date_idx="$(get_column_index "last_verified_date")" +evidence_refs_idx="$(get_column_index "evidence_refs")" + +seen_requirements=() +row_count=0 + +line_no=$((header_line_number + 2)) +while true; do + line="$(sed -n "${line_no}p" "$MATRIX_PATH")" + [[ -z "$line" ]] && break + + if [[ ! "$line" =~ ^\| ]]; then + line_no=$((line_no + 1)) + continue + fi + + cells=() + extract_cells "$line" cells + [[ "${#cells[@]}" -lt "${#header_cells[@]}" ]] && fail "row has fewer cells than header at line ${line_no}" + + requirement_id="${cells[$requirement_idx]}" + feature_slug="${cells[$feature_slug_idx]}" + implementation_refs="${cells[$implementation_refs_idx]}" + test_refs="${cells[$test_refs_idx]}" + status="${cells[$status_idx]}" + last_verified_date="${cells[$last_verified_date_idx]}" + evidence_refs="${cells[$evidence_refs_idx]}" + + [[ -n "$requirement_id" ]] || fail "empty requirement_id at line ${line_no}" + contains "$feature_slug" "${allowed_feature_slugs[@]}" || fail "invalid feature_slug '${feature_slug}' at line ${line_no}" + contains "$status" "${allowed_statuses[@]}" || fail "invalid status '${status}' at line ${line_no}" + + if [[ "$status" == "verified" ]]; then + [[ "$last_verified_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "verified row missing ISO date at line ${line_no}" + fi + + validate_ref_list "implementation_refs" "$implementation_refs" + validate_ref_list "test_refs" "$test_refs" + validate_ref_list "evidence_refs" "$evidence_refs" + + seen_requirements+=("$requirement_id") + row_count=$((row_count + 1)) + line_no=$((line_no + 1)) +done + +[[ "$row_count" -gt 0 ]] || fail "no matrix rows found" + +mapfile -t epic_requirements < <(grep -E '^### (FR|NFR)-' "$EPIC_PATH" | awk '{print $2}') +[[ "${#epic_requirements[@]}" -gt 0 ]] || fail "no FR/NFR requirement headers found in epic PRD" + +for req in "${epic_requirements[@]}"; do + if ! printf '%s\n' "${seen_requirements[@]}" | grep -Fxq "$req"; then + fail "missing matrix row for requirement '${req}'" + fi +done + +echo "matrix lint passed: ${MATRIX_PATH}" diff --git a/src/lightbulb.gleam b/src/lightbulb.gleam index d457221..9bcfe5a 100644 --- a/src/lightbulb.gleam +++ b/src/lightbulb.gleam @@ -1,21 +1,15 @@ -import lightbulb/providers/data_provider -import lightbulb/tool +//// # lightbulb +//// +//// `lightbulb` is a Gleam library for building LTI 1.3 tool providers. +//// +//// ## Modules +//// +//// - Core launch and OIDC flow: `lightbulb/tool` +//// - Deep Linking 2.0: `lightbulb/deep_linking` +//// - AGS (Assignments and Grades): `lightbulb/services/ags` +//// - NRPS (Names and Roles): `lightbulb/services/nrps` +//// - OAuth service access tokens: `lightbulb/services/access_token` +//// - Optional OAuth token cache helpers: `lightbulb/services/access_token_cache` +//// - Provider interfaces: `lightbulb/providers/data_provider`, +//// `lightbulb/providers/http_provider` -// Export tool module types and functions for convinience - -/// A DataProvider is a record type that implements the required functions to -/// provide data to the tool. See the `lightbulb/providers/data_provider` module for -/// more details. -pub type DataProvider = - data_provider.DataProvider - -/// Builds an OIDC login response for the tool. This function will return a `state` and `redirect_url`. -/// The `state` is an opaque string that will be used to verify the response from the -/// OIDC provider. The `redirect_url` is the URL that the user will be redirected to -/// to authenticate and complete the OIDC login process. -pub const oidc_login = tool.oidc_login - -/// Validates the OIDC login response from the OIDC provider. This function will validate and unpack -/// the `id_token` and return claims as a map if the token is valid. The `state` parametrer is the -/// opaque string that was stored in a cookie during `oidc_login` step. -pub const validate_launch = tool.validate_launch diff --git a/src/lightbulb/deep_linking.gleam b/src/lightbulb/deep_linking.gleam new file mode 100644 index 0000000..be4b111 --- /dev/null +++ b/src/lightbulb/deep_linking.gleam @@ -0,0 +1,297 @@ +//// # Deep Linking +//// +//// Deep Linking 2.0 helpers for decoding settings and building response JWTs. +//// +//// ## Example +//// +//// ```gleam +//// import gleam/http/response +//// import gleam/option +//// import gleam/result +//// import lightbulb/deep_linking +//// import lightbulb/deep_linking/content_item +//// import lightbulb/errors +//// import wisp.{type Request, type Response} +//// +//// pub fn deep_linking_response( +//// _req: Request, +//// data_provider, +//// claims, +//// ) -> Response { +//// case build_deep_linking_response_html(data_provider, claims) { +//// Ok(html) -> +//// wisp.ok() +//// |> response.set_header("content-type", "text/html; charset=utf-8") +//// |> wisp.string_body(html) +//// +//// Error(error) -> +//// wisp.bad_request() +//// |> wisp.string_body( +//// "Deep linking response failed: " +//// <> errors.deep_linking_error_to_string(error), +//// ) +//// } +//// } +//// +//// fn build_deep_linking_response_html(data_provider, claims) { +//// use settings <- result.try(deep_linking.get_deep_linking_settings(claims)) +//// use active_jwk <- result.try(data_provider.get_active_jwk()) +//// +//// let items = [ +//// content_item.lti_resource_link( +//// option.Some("https://tool.example.com/launch/resource-1"), +//// option.Some("Resource 1"), +//// option.None, +//// option.None, +//// option.None, +//// ), +//// ] +//// +//// use jwt <- result.try( +//// deep_linking.build_response_jwt( +//// request_claims: claims, +//// settings: settings, +//// items: items, +//// options: deep_linking.default_response_options(), +//// active_jwk: active_jwk, +//// ), +//// ) +//// +//// deep_linking.build_response_form_post(settings.deep_link_return_url, jwt) +//// } +//// ``` +import gleam/dict +import gleam/dynamic +import gleam/dynamic/decode +import gleam/list +import gleam/option.{type Option} +import gleam/result +import gleam/string +import gleam/time/duration +import gleam/time/timestamp +import gleam/uri +import lightbulb/deep_linking/content_item.{type ContentItem} +import lightbulb/deep_linking/settings.{type DeepLinkingSettings} +import lightbulb/errors.{ + type DeepLinkingError, DeepLinkingClaimInvalid, DeepLinkingClaimMissing, + DeepLinkingResponseInvalidReturnUrl, DeepLinkingResponseSigningFailed, +} +import lightbulb/jose +import lightbulb/jwk.{type Jwk} + +pub const lti_message_type_deep_linking_request = "LtiDeepLinkingRequest" + +pub const lti_message_type_deep_linking_response = "LtiDeepLinkingResponse" + +pub const lti_version = "1.3.0" + +pub const claim_message_type = "https://purl.imsglobal.org/spec/lti/claim/message_type" + +pub const claim_version = "https://purl.imsglobal.org/spec/lti/claim/version" + +pub const claim_deployment_id = "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + +pub const claim_data = "https://purl.imsglobal.org/spec/lti-dl/claim/data" + +pub const claim_content_items = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" + +pub const claim_msg = "https://purl.imsglobal.org/spec/lti-dl/claim/msg" + +pub const claim_log = "https://purl.imsglobal.org/spec/lti-dl/claim/log" + +pub const claim_errormsg = "https://purl.imsglobal.org/spec/lti-dl/claim/errormsg" + +pub const claim_errorlog = "https://purl.imsglobal.org/spec/lti-dl/claim/errorlog" + +pub type DeepLinkingResponseOptions { + DeepLinkingResponseOptions( + msg: Option(String), + log: Option(String), + errormsg: Option(String), + errorlog: Option(String), + ttl_seconds: Int, + ) +} + +/// Returns default options for deep-linking responses. +/// +/// Defaults: +/// - no optional message/log/error fields +/// - `ttl_seconds = 300` +pub fn default_response_options() -> DeepLinkingResponseOptions { + DeepLinkingResponseOptions( + msg: option.None, + log: option.None, + errormsg: option.None, + errorlog: option.None, + ttl_seconds: 300, + ) +} + +fn unix_seconds(value: timestamp.Timestamp) -> Int { + timestamp.to_unix_seconds_and_nanoseconds(value).0 +} + +/// Decodes deep-linking settings from validated launch claims. +/// +/// Returns: +/// - `Ok(DeepLinkingSettings)` when the claim is present and valid +/// - `Error(DeepLinkingClaimMissing)` when the settings claim is absent +/// - `Error(DeepLinkingSettingsInvalid)` when the claim shape is invalid +pub fn get_deep_linking_settings( + claims: jose.Claims, +) -> Result(DeepLinkingSettings, DeepLinkingError) { + settings.from_claims(claims) +} + +/// Builds and signs a Deep Linking response JWT. +/// +/// This function validates return URL and content items, enforces required response +/// claims, conditionally echoes request `data`, and signs with RS256 using the +/// provided active JWK. +pub fn build_response_jwt( + request_claims: jose.Claims, + settings: DeepLinkingSettings, + items: List(ContentItem), + options: DeepLinkingResponseOptions, + active_jwk: Jwk, +) -> Result(String, DeepLinkingError) { + use _ <- result.try(validate_return_url(settings.deep_link_return_url)) + use _ <- result.try(content_item.validate_items(settings, items)) + use iss <- result.try(required_claim_string(request_claims, "iss")) + use deployment_id <- result.try(required_claim_string( + request_claims, + claim_deployment_id, + )) + + let base_claims = + dict.from_list([ + #("aud", dynamic.string(iss)), + #( + claim_message_type, + dynamic.string(lti_message_type_deep_linking_response), + ), + #(claim_version, dynamic.string(lti_version)), + #(claim_deployment_id, dynamic.string(deployment_id)), + #( + "iat", + timestamp.system_time() + |> unix_seconds + |> dynamic.int, + ), + #( + "exp", + timestamp.system_time() + |> timestamp.add(duration.seconds(options.ttl_seconds)) + |> unix_seconds + |> dynamic.int(), + ), + ]) + + let claims = + base_claims + |> maybe_add_optional(settings.data, claim_data, dynamic.string) + |> maybe_add_optional( + case items != [] { + True -> + option.Some(dynamic.list(list.map(items, content_item.to_dynamic))) + False -> option.None + }, + claim_content_items, + fn(value) { value }, + ) + |> maybe_add_optional(options.msg, claim_msg, dynamic.string) + |> maybe_add_optional(options.log, claim_log, dynamic.string) + |> maybe_add_optional(options.errormsg, claim_errormsg, dynamic.string) + |> maybe_add_optional(options.errorlog, claim_errorlog, dynamic.string) + + sign_claims(claims, active_jwk) +} + +/// Builds an auto-submit HTML form that POSTs the response JWT to the platform. +/// +/// The form posts the token under the required parameter name: `JWT`. +pub fn build_response_form_post( + deep_link_return_url: String, + jwt: String, +) -> Result(String, DeepLinkingError) { + use _ <- result.try(validate_return_url(deep_link_return_url)) + + Ok( + "" + <> "
escape_html_attribute(deep_link_return_url) + <> "\" method=\"POST\">" + <> " escape_html_attribute(jwt) + <> "\"/>" + <> "
" + <> "" + <> "", + ) +} + +fn validate_return_url(url: String) -> Result(Nil, DeepLinkingError) { + case uri.parse(url) { + Ok(parsed) -> { + case parsed.scheme { + option.Some("http") | option.Some("https") -> Ok(Nil) + _ -> Error(DeepLinkingResponseInvalidReturnUrl) + } + } + Error(_) -> Error(DeepLinkingResponseInvalidReturnUrl) + } +} + +fn sign_claims( + claims: dict.Dict(String, dynamic.Dynamic), + active_jwk: Jwk, +) -> Result(String, DeepLinkingError) { + let #(_, jwk_map) = jwk.to_map(active_jwk) + let jws = + dict.from_list([ + #("alg", "RS256"), + #("typ", "JWT"), + #("kid", active_jwk.kid), + ]) + + let #(_, jose_jwt) = jose.sign_with_jws(jwk_map, jws, claims) + let #(_, compact_signed) = jose.compact(jose_jwt) + + case string.is_empty(compact_signed) { + True -> Error(DeepLinkingResponseSigningFailed) + False -> Ok(compact_signed) + } +} + +fn required_claim_string( + claims: jose.Claims, + claim_name: String, +) -> Result(String, DeepLinkingError) { + dict.get(claims, claim_name) + |> result.map_error(fn(_) { DeepLinkingClaimMissing }) + |> result.try(fn(raw) { + decode.run(raw, decode.string) + |> result.map_error(fn(_) { DeepLinkingClaimInvalid }) + }) +} + +fn maybe_add_optional( + claims: dict.Dict(String, dynamic.Dynamic), + value: Option(a), + key: String, + encoder: fn(a) -> dynamic.Dynamic, +) { + case value { + option.Some(value) -> dict.insert(claims, key, encoder(value)) + option.None -> claims + } +} + +fn escape_html_attribute(value: String) -> String { + value + |> string.replace("&", "&") + |> string.replace("\"", """) + |> string.replace("<", "<") + |> string.replace(">", ">") +} diff --git a/src/lightbulb/deep_linking/content_item.gleam b/src/lightbulb/deep_linking/content_item.gleam new file mode 100644 index 0000000..d84e4b8 --- /dev/null +++ b/src/lightbulb/deep_linking/content_item.gleam @@ -0,0 +1,200 @@ +import gleam/bool +import gleam/dict +import gleam/dynamic +import gleam/list +import gleam/option.{type Option} +import lightbulb/deep_linking/settings.{type DeepLinkingSettings} +import lightbulb/errors.{ + type DeepLinkingError, DeepLinkingResponseInvalidItemType, + DeepLinkingResponseMultipleNotAllowed, +} + +pub type LineItem { + LineItem( + score_maximum: Float, + resource_id: Option(String), + tag: Option(String), + label: Option(String), + grades_released: Option(Bool), + ) +} + +/// Content items supported for deep-linking responses. +pub type ContentItem { + Link(url: String, title: Option(String), text: Option(String)) + LtiResourceLink( + url: Option(String), + title: Option(String), + text: Option(String), + custom: Option(dict.Dict(String, String)), + line_item: Option(LineItem), + ) + File(url: String, title: Option(String), media_type: Option(String)) + Html(html: String) + Image(url: String, title: Option(String), alt: Option(String)) +} + +/// Creates a `link` content item. +pub fn link( + url: String, + title: Option(String), + text: Option(String), +) -> ContentItem { + Link(url: url, title: title, text: text) +} + +/// Creates an `ltiResourceLink` content item. +pub fn lti_resource_link( + url: Option(String), + title: Option(String), + text: Option(String), + custom: Option(dict.Dict(String, String)), + line_item: Option(LineItem), +) -> ContentItem { + LtiResourceLink( + url: url, + title: title, + text: text, + custom: custom, + line_item: line_item, + ) +} + +/// Returns the Deep Linking item type string for a content item. +pub fn item_type(item: ContentItem) -> String { + case item { + Link(..) -> "link" + LtiResourceLink(..) -> "ltiResourceLink" + File(..) -> "file" + Html(..) -> "html" + Image(..) -> "image" + } +} + +/// Validates content items against deep-linking settings constraints. +/// +/// Enforced rules: +/// - item type must be listed in `accept_types` +/// - if `accept_multiple` is false, only one item may be returned +/// - if `accept_lineitem` is false, lineItem payloads are rejected +pub fn validate_items( + settings: DeepLinkingSettings, + items: List(ContentItem), +) -> Result(Nil, DeepLinkingError) { + use <- bool.guard( + when: !all_item_types_allowed(items, settings.accept_types), + return: Error(DeepLinkingResponseInvalidItemType), + ) + + use <- bool.guard( + when: option.unwrap(settings.accept_multiple, False) == False + && list.length(items) > 1, + return: Error(DeepLinkingResponseMultipleNotAllowed), + ) + + use <- bool.guard( + when: option.unwrap(settings.accept_lineitem, False) == False + && list.any(items, fn(item) { item_has_line_item(item) }), + return: Error(DeepLinkingResponseInvalidItemType), + ) + + Ok(Nil) +} + +fn all_item_types_allowed( + items: List(ContentItem), + accept_types: List(String), +) -> Bool { + list.all(items, fn(item) { list.contains(accept_types, item_type(item)) }) +} + +fn item_has_line_item(item: ContentItem) -> Bool { + case item { + LtiResourceLink(line_item: option.Some(_), ..) -> True + _ -> False + } +} + +/// Encodes a content item to dynamic data for JWT claim construction. +pub fn to_dynamic(item: ContentItem) -> dynamic.Dynamic { + case item { + Link(url, title, text) -> + [ + #("type", dynamic.string("link")), + #("url", dynamic.string(url)), + ] + |> maybe_add_optional(title, "title", dynamic.string) + |> maybe_add_optional(text, "text", dynamic.string) + |> object() + + LtiResourceLink(url, title, text, custom, line_item) -> + [ + #("type", dynamic.string("ltiResourceLink")), + ] + |> maybe_add_optional(url, "url", dynamic.string) + |> maybe_add_optional(title, "title", dynamic.string) + |> maybe_add_optional(text, "text", dynamic.string) + |> maybe_add_optional(custom, "custom", custom_to_dynamic) + |> maybe_add_optional(line_item, "lineItem", line_item_to_dynamic) + |> object() + + File(url, title, media_type) -> + [ + #("type", dynamic.string("file")), + #("url", dynamic.string(url)), + ] + |> maybe_add_optional(title, "title", dynamic.string) + |> maybe_add_optional(media_type, "mediaType", dynamic.string) + |> object() + + Html(html) -> + [#("type", dynamic.string("html")), #("html", dynamic.string(html))] + |> object() + + Image(url, title, alt) -> + [ + #("type", dynamic.string("image")), + #("url", dynamic.string(url)), + ] + |> maybe_add_optional(title, "title", dynamic.string) + |> maybe_add_optional(alt, "alt", dynamic.string) + |> object() + } +} + +fn line_item_to_dynamic(item: LineItem) -> dynamic.Dynamic { + let LineItem(score_maximum, resource_id, tag, label, grades_released) = item + + [ + #("scoreMaximum", dynamic.float(score_maximum)), + ] + |> maybe_add_optional(resource_id, "resourceId", dynamic.string) + |> maybe_add_optional(tag, "tag", dynamic.string) + |> maybe_add_optional(label, "label", dynamic.string) + |> maybe_add_optional(grades_released, "gradesReleased", dynamic.bool) + |> object() +} + +fn custom_to_dynamic(custom: dict.Dict(String, String)) -> dynamic.Dynamic { + custom + |> dict.to_list() + |> list.map(fn(pair) { #(dynamic.string(pair.0), dynamic.string(pair.1)) }) + |> dynamic.properties() +} + +fn object(entries: List(#(String, dynamic.Dynamic))) -> dynamic.Dynamic { + entries + |> list.map(fn(entry) { #(dynamic.string(entry.0), entry.1) }) + |> dynamic.properties() +} + +fn maybe_add_optional( + entries: List(#(String, dynamic.Dynamic)), + value: Option(a), + key: String, + encoder: fn(a) -> dynamic.Dynamic, +) { + value + |> option.map(fn(actual) { [#(key, encoder(actual)), ..entries] }) + |> option.unwrap(entries) +} diff --git a/src/lightbulb/deep_linking/settings.gleam b/src/lightbulb/deep_linking/settings.gleam new file mode 100644 index 0000000..e5083c3 --- /dev/null +++ b/src/lightbulb/deep_linking/settings.gleam @@ -0,0 +1,98 @@ +import gleam/dict +import gleam/dynamic.{type Dynamic} +import gleam/dynamic/decode +import gleam/option.{type Option, None} +import gleam/result +import lightbulb/errors.{DeepLinkingClaimMissing, DeepLinkingSettingsInvalid} + +pub const claim_deep_linking_settings = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + +pub type DeepLinkingSettings { + DeepLinkingSettings( + deep_link_return_url: String, + accept_types: List(String), + accept_presentation_document_targets: List(String), + accept_media_types: Option(String), + accept_multiple: Option(Bool), + auto_create: Option(Bool), + title: Option(String), + text: Option(String), + data: Option(String), + accept_lineitem: Option(Bool), + ) +} + +/// Decoder for the `deep_linking_settings` claim payload. +pub fn decoder() { + use deep_link_return_url <- decode.field( + "deep_link_return_url", + decode.string, + ) + use accept_types <- decode.field("accept_types", decode.list(decode.string)) + use accept_presentation_document_targets <- decode.field( + "accept_presentation_document_targets", + decode.list(decode.string), + ) + use accept_media_types <- decode.optional_field( + "accept_media_types", + None, + decode.optional(decode.string), + ) + use accept_multiple <- decode.optional_field( + "accept_multiple", + None, + decode.optional(decode.bool), + ) + use auto_create <- decode.optional_field( + "auto_create", + None, + decode.optional(decode.bool), + ) + use title <- decode.optional_field( + "title", + None, + decode.optional(decode.string), + ) + use text <- decode.optional_field( + "text", + None, + decode.optional(decode.string), + ) + use data <- decode.optional_field( + "data", + None, + decode.optional(decode.string), + ) + use accept_lineitem <- decode.optional_field( + "accept_lineitem", + None, + decode.optional(decode.bool), + ) + + decode.success(DeepLinkingSettings( + deep_link_return_url: deep_link_return_url, + accept_types: accept_types, + accept_presentation_document_targets: accept_presentation_document_targets, + accept_media_types: accept_media_types, + accept_multiple: accept_multiple, + auto_create: auto_create, + title: title, + text: text, + data: data, + accept_lineitem: accept_lineitem, + )) +} + +/// Extracts and decodes deep-linking settings from launch claims. +/// +/// Returns: +/// - `Error(DeepLinkingClaimMissing)` when settings claim is absent +/// - `Error(DeepLinkingSettingsInvalid)` when claim fails decode/validation +pub fn from_claims(claims: dict.Dict(String, Dynamic)) { + dict.get(claims, claim_deep_linking_settings) + |> result.map_error(fn(_) { DeepLinkingClaimMissing }) + |> result.try(fn(raw) { + decode.run(raw, decoder()) + |> result.map_error(fn(_) { DeepLinkingSettingsInvalid }) + }) +} diff --git a/src/lightbulb/errors.gleam b/src/lightbulb/errors.gleam new file mode 100644 index 0000000..b83a7f2 --- /dev/null +++ b/src/lightbulb/errors.gleam @@ -0,0 +1,78 @@ +pub type NonceError { + NonceInvalid + NonceExpired + NonceReplayed +} + +/// Converts nonce-validation errors to human-readable messages. +pub fn nonce_error_to_string(error: NonceError) -> String { + case error { + NonceInvalid -> "Invalid nonce." + NonceExpired -> "Nonce has expired." + NonceReplayed -> "Nonce has already been used." + } +} + +pub type CoreError { + LoginMissingParam(param: String) + LaunchMissingParam(param: String) + JwtInvalidSignature + JwtInvalidClaim + JwtExpired + JwtNotYetValid + AudienceInvalid + RegistrationNotFound + DeploymentNotFound + StateInvalid + StateNotFound + TargetLinkUriMismatch + NonceValidationError(error: NonceError) + MessageTypeUnsupported +} + +/// Converts core launch-validation errors to human-readable messages. +pub fn core_error_to_string(error: CoreError) -> String { + case error { + LoginMissingParam(param) -> + "Missing required login parameter: " <> param <> "." + LaunchMissingParam(param) -> + "Missing required launch parameter: " <> param <> "." + JwtInvalidSignature -> "Invalid token signature." + JwtInvalidClaim -> "Token claims are invalid." + JwtExpired -> "Token has expired." + JwtNotYetValid -> "Token is not yet valid." + AudienceInvalid -> "Token audience is invalid." + RegistrationNotFound -> "No matching registration was found." + DeploymentNotFound -> "No matching deployment was found." + StateInvalid -> "Invalid state." + StateNotFound -> "State context was not found or has expired." + TargetLinkUriMismatch -> "Target link URI does not match login context." + NonceValidationError(nonce_error) -> nonce_error_to_string(nonce_error) + MessageTypeUnsupported -> "Unsupported LTI message type." + } +} + +pub type DeepLinkingError { + DeepLinkingClaimMissing + DeepLinkingClaimInvalid + DeepLinkingSettingsInvalid + DeepLinkingResponseInvalidItemType + DeepLinkingResponseMultipleNotAllowed + DeepLinkingResponseInvalidReturnUrl + DeepLinkingResponseSigningFailed +} + +/// Converts deep-linking errors to human-readable messages. +pub fn deep_linking_error_to_string(error: DeepLinkingError) -> String { + case error { + DeepLinkingClaimMissing -> "Missing required deep-linking claim." + DeepLinkingClaimInvalid -> "Deep-linking claim has invalid format." + DeepLinkingSettingsInvalid -> "Deep-linking settings are invalid." + DeepLinkingResponseInvalidItemType -> + "Response contains an unsupported item type." + DeepLinkingResponseMultipleNotAllowed -> + "Multiple items are not allowed for this deep-linking request." + DeepLinkingResponseInvalidReturnUrl -> "Deep-link return URL is invalid." + DeepLinkingResponseSigningFailed -> "Failed to sign deep-linking response." + } +} diff --git a/src/lightbulb/http/link_header.gleam b/src/lightbulb/http/link_header.gleam new file mode 100644 index 0000000..7787f03 --- /dev/null +++ b/src/lightbulb/http/link_header.gleam @@ -0,0 +1,157 @@ +import gleam/bool +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string + +pub type LinkHeaderError { + InvalidLinkHeader(reason: String) +} + +pub type PageLinks { + PageLinks( + next: Option(String), + differences: Option(String), + prev: Option(String), + first: Option(String), + last: Option(String), + ) +} + +/// Returns an empty set of pagination links. +pub fn empty_page_links() -> PageLinks { + PageLinks(next: None, differences: None, prev: None, first: None, last: None) +} + +/// Parses a raw HTTP `Link` header into normalized page relations. +pub fn parse(header: String) -> Result(PageLinks, LinkHeaderError) { + parse_segments(string.split(header, ","), empty_page_links()) +} + +/// Converts link-header parsing errors to human-readable messages. +pub fn link_header_error_to_string(error: LinkHeaderError) -> String { + case error { + InvalidLinkHeader(reason) -> reason + } +} + +fn parse_segments( + segments: List(String), + acc: PageLinks, +) -> Result(PageLinks, LinkHeaderError) { + case segments { + [] -> Ok(acc) + [segment, ..rest] -> { + use parsed <- result.try(parse_link_segment(segment)) + parse_segments(rest, merge_links(acc, parsed)) + } + } +} + +fn parse_link_segment(segment: String) -> Result(PageLinks, LinkHeaderError) { + let trimmed = string.trim(segment) + let parts = string.split(trimmed, ";") + + case parts { + [url_part, ..params] -> { + use url <- result.try(extract_url(url_part)) + use relations <- result.try(extract_relations(params)) + + Ok(assign_relations(url, relations, empty_page_links())) + } + _ -> Error(InvalidLinkHeader("missing link segment")) + } +} + +fn extract_url(raw: String) -> Result(String, LinkHeaderError) { + let trimmed = string.trim(raw) + + use <- bool.guard( + when: !string.starts_with(trimmed, "<") || !string.ends_with(trimmed, ">"), + return: Error(InvalidLinkHeader("link URL is not wrapped in angle brackets")), + ) + + Ok( + trimmed + |> string.drop_start(1) + |> string.drop_end(1), + ) +} + +fn extract_relations( + params: List(String), +) -> Result(List(String), LinkHeaderError) { + let rel_params = + params + |> list.filter(fn(param) { + string.trim(param) + |> string.starts_with("rel=") + }) + + case rel_params { + [] -> Error(InvalidLinkHeader("link relation is missing")) + [rel_param, ..] -> { + let rel_value = + rel_param + |> string.trim() + |> string.drop_start(4) + |> string.trim() + |> string.drop_start(1) + |> string.drop_end(1) + + Ok( + rel_value + |> string.split(" ") + |> list.filter(fn(value) { string.trim(value) != "" }) + |> list.map(string.trim), + ) + } + } +} + +fn assign_relations( + url: String, + relations: List(String), + links: PageLinks, +) -> PageLinks { + case relations { + [] -> links + [relation, ..rest] -> { + let updated = case relation { + "next" -> PageLinks(..links, next: Some(url)) + "differences" -> PageLinks(..links, differences: Some(url)) + "prev" -> PageLinks(..links, prev: Some(url)) + "first" -> PageLinks(..links, first: Some(url)) + "last" -> PageLinks(..links, last: Some(url)) + _ -> links + } + + assign_relations(url, rest, updated) + } + } +} + +fn merge_links(a: PageLinks, b: PageLinks) -> PageLinks { + let PageLinks( + next: next_a, + differences: differences_a, + prev: prev_a, + first: first_a, + last: last_a, + ) = a + let PageLinks( + next: next_b, + differences: differences_b, + prev: prev_b, + first: first_b, + last: last_b, + ) = b + + PageLinks( + next: option.or(next_a, next_b), + differences: option.or(differences_a, differences_b), + prev: option.or(prev_a, prev_b), + first: option.or(first_a, first_b), + last: option.or(last_a, last_b), + ) +} diff --git a/src/lightbulb/jose.gleam b/src/lightbulb/jose.gleam index 3bb4b71..6448f8c 100644 --- a/src/lightbulb/jose.gleam +++ b/src/lightbulb/jose.gleam @@ -18,12 +18,14 @@ pub type JoseJws { } @external(erlang, "jose_jwt", "sign") +/// Signs string claims with the provided JWK. pub fn sign( jwk: Dict(String, String), token: Dict(String, String), ) -> #(JoseJws, Dict(String, String)) @external(erlang, "jose_jwt", "sign") +/// Signs dynamic claims with an explicit JWS header map. pub fn sign_with_jws( jwk: Dict(String, String), jws: Dict(String, String), @@ -31,31 +33,40 @@ pub fn sign_with_jws( ) -> #(JoseJws, Dict(String, String)) @external(erlang, "jose_jws", "compact") +/// Serializes a signed JWS/JWT map into compact form. pub fn compact(signed: Dict(String, String)) -> #(Dict(String, String), String) @external(erlang, "jose_jwt", "verify") +/// Verifies a compact JWT with a JWK. pub fn verify(jwk: JoseJwk, signed_token: String) -> #(Bool, JoseJwt, JoseJws) @external(erlang, "jose_jwt", "peek") +/// Reads JWT claims without signature verification. pub fn peek(jwt_string: String) -> JoseJwt @external(erlang, "jose_jwt", "peek_protected") +/// Reads protected JWS headers without signature verification. pub fn peek_protected(jwt_string: String) -> JoseJws @external(erlang, "jose_jwt", "to_binary") +/// Serializes a JWT structure to a compact token string. pub fn to_binary(jwt: JoseJwt) -> String @external(erlang, "jose_jwt", "from_binary") +/// Parses a compact token string into a JWT structure. pub fn from_binary(jwt_string: String) -> JoseJwt /// JWK @external(erlang, "jose_jwk", "from_map") +/// Builds a JOSE JWK from a raw map. pub fn from_map(map: Dict(String, String)) -> JoseJwk @external(erlang, "jose_jwk", "from_pem") +/// Builds a JOSE JWK from PEM content. pub fn from_pem(pem: String) -> JoseJwk @external(erlang, "jose_jwk", "to_public") +/// Returns the public portion of a JWK. pub fn to_public(jwk: JoseJwk) -> JoseJwk /// This function will take a JoseJwk and convert it to a PEM string. @@ -63,9 +74,11 @@ pub fn to_public(jwk: JoseJwk) -> JoseJwk /// parameters and pem is the PEM string. The params map should contain /// the Kty (the key type). @external(erlang, "jose_jwk", "to_pem") +/// Converts a JWK into JOSE params plus PEM string. pub fn to_pem(jwk: JoseJwk) -> #(Dict(String, Dynamic), String) @external(erlang, "jose_jwk", "to_map") +/// Converts a JWK into JOSE params and key map values. pub fn to_map(jwk: JoseJwk) -> #(Dict(String, Dynamic), Dict(String, String)) pub type GenerateKeyParams { @@ -73,8 +86,5 @@ pub type GenerateKeyParams { } @external(erlang, "jose_jwk", "generate_key") +/// Generates a keypair from the provided parameters. pub fn generate_key(params: GenerateKeyParams) -> JoseJwk - -/// UTILITY FUNCTIONS -@external(erlang, "erlang", "display") -pub fn erlang_display(term: a) -> Bool diff --git a/src/lightbulb/jwk.gleam b/src/lightbulb/jwk.gleam index 214cf6a..e43a531 100644 --- a/src/lightbulb/jwk.gleam +++ b/src/lightbulb/jwk.gleam @@ -8,6 +8,7 @@ pub type Jwk { Jwk(kid: String, typ: String, alg: String, pem: String) } +/// Decodes a single persisted JWK record. pub fn jwk_decoder() { use kid <- decode.field("kid", decode.string) use typ <- decode.optional_field("typ", "JWT", decode.string) @@ -17,12 +18,14 @@ pub fn jwk_decoder() { decode.success(Jwk(kid, typ, alg, pem)) } +/// Decodes a JWK set payload with a `keys` field. pub fn jwks_decoder() { use keys <- decode.field("keys", decode.list(jwk_decoder())) decode.success(keys) } +/// Converts a persisted JWK into JOSE map values. pub fn to_map(jwk: Jwk) { let Jwk(pem: pem, ..) = jwk @@ -30,6 +33,7 @@ pub fn to_map(jwk: Jwk) { |> jose.to_map() } +/// Builds a persisted `Jwk` value from JOSE map values and explicit key id. pub fn from_map(kid: String, map: dict.Dict(String, String)) { let typ = dict.get(map, "typ") |> result.unwrap("JWT") let alg = dict.get(map, "alg") |> result.unwrap("RS256") @@ -38,6 +42,7 @@ pub fn from_map(kid: String, map: dict.Dict(String, String)) { Ok(Jwk(kid, typ, alg, pem)) } +/// Generates a new RSA keypair and returns it as a persisted `Jwk`. pub fn generate() { let kid = uuid.v4_string() diff --git a/src/lightbulb/nonce.gleam b/src/lightbulb/nonce.gleam index 99455cb..44a3e46 100644 --- a/src/lightbulb/nonce.gleam +++ b/src/lightbulb/nonce.gleam @@ -1,6 +1,6 @@ -import birl.{type Time} +import gleam/time/timestamp.{type Timestamp} /// Represents a nonce used for security purposes, such as preventing replay attacks. pub type Nonce { - Nonce(nonce: String, expires_at: Time) + Nonce(nonce: String, expires_at: Timestamp) } diff --git a/src/lightbulb/providers/data_provider.gleam b/src/lightbulb/providers/data_provider.gleam index 506b7be..ea0e8dc 100644 --- a/src/lightbulb/providers/data_provider.gleam +++ b/src/lightbulb/providers/data_provider.gleam @@ -1,17 +1,95 @@ +//// # Data Provider Interface +//// +//// Storage/provider contracts required by `tool.oidc_login` and +//// `tool.validate_launch`. +//// +//// ## Example +//// +//// ```gleam +//// import lightbulb/providers/data_provider.{type DataProvider} +//// +//// fn build_data_provider( +//// create_nonce, +//// validate_nonce, +//// save_login_context, +//// get_login_context, +//// consume_login_context, +//// get_registration, +//// get_deployment, +//// get_active_jwk, +//// ) -> DataProvider { +//// DataProvider( +//// create_nonce: create_nonce, +//// validate_nonce: validate_nonce, +//// save_login_context: save_login_context, +//// get_login_context: get_login_context, +//// consume_login_context: consume_login_context, +//// get_registration: get_registration, +//// get_deployment: get_deployment, +//// get_active_jwk: get_active_jwk, +//// ) +//// } +//// ``` +import gleam/time/timestamp.{type Timestamp} import lightbulb/deployment.{type Deployment} +import lightbulb/errors.{type NonceError} import lightbulb/jwk.{type Jwk} import lightbulb/nonce.{type Nonce} import lightbulb/registration.{type Registration} +pub type LoginContext { + LoginContext( + state: String, + target_link_uri: String, + issuer: String, + client_id: String, + expires_at: Timestamp, + ) +} + +pub type LaunchContextError { + LaunchContextInvalid + LaunchContextNotFound +} + +/// Converts launch-context errors to human-readable messages. +pub fn launch_context_error_to_string(error: LaunchContextError) -> String { + case error { + LaunchContextInvalid -> "Launch context is invalid." + LaunchContextNotFound -> "Launch context was not found." + } +} + +pub type ProviderError { + ProviderCreateNonceFailed + ProviderRegistrationNotFound + ProviderDeploymentNotFound + ProviderActiveJwkNotFound +} + +/// Converts provider errors to human-readable messages. +pub fn provider_error_to_string(error: ProviderError) -> String { + case error { + ProviderCreateNonceFailed -> "Failed to create nonce." + ProviderRegistrationNotFound -> "Registration not found." + ProviderDeploymentNotFound -> "Deployment not found." + ProviderActiveJwkNotFound -> "Active JWK not found." + } +} + /// Represents a data provider that can handle various operations related to /// LTI (Learning Tools Interoperability) such as creating nonces, /// validating nonces, retrieving registrations, deployments, and active JWKs. pub type DataProvider { DataProvider( - create_nonce: fn() -> Result(Nonce, String), - validate_nonce: fn(String) -> Result(Nil, String), - get_registration: fn(String, String) -> Result(Registration, String), - get_deployment: fn(String, String, String) -> Result(Deployment, String), - get_active_jwk: fn() -> Result(Jwk, String), + create_nonce: fn() -> Result(Nonce, ProviderError), + validate_nonce: fn(String) -> Result(Nil, NonceError), + save_login_context: fn(LoginContext) -> Result(Nil, LaunchContextError), + get_login_context: fn(String) -> Result(LoginContext, LaunchContextError), + consume_login_context: fn(String) -> Result(Nil, LaunchContextError), + get_registration: fn(String, String) -> Result(Registration, ProviderError), + get_deployment: fn(String, String, String) -> + Result(Deployment, ProviderError), + get_active_jwk: fn() -> Result(Jwk, ProviderError), ) } diff --git a/src/lightbulb/providers/http_provider.gleam b/src/lightbulb/providers/http_provider.gleam index 09d8335..8dcacc4 100644 --- a/src/lightbulb/providers/http_provider.gleam +++ b/src/lightbulb/providers/http_provider.gleam @@ -1,3 +1,24 @@ +//// # HTTP Provider Interface +//// +//// Transport abstraction used by service modules for outbound HTTP calls. +//// +//// ## Example +//// +//// ```gleam +//// import gleam/httpc +//// import gleam/result +//// import gleam/string +//// import lightbulb/providers/http_provider.{HttpProvider} +//// +//// pub fn http_provider() -> HttpProvider { +//// HttpProvider(send: fn(req) { +//// httpc.send(req) +//// |> result.map_error(fn(error) { +//// "HTTP transport error: " <> string.inspect(error) +//// }) +//// }) +//// } +//// ``` import gleam/http/request.{type Request} import gleam/http/response.{type Response} diff --git a/src/lightbulb/providers/memory_provider.gleam b/src/lightbulb/providers/memory_provider.gleam index 76aaad5..65e1678 100644 --- a/src/lightbulb/providers/memory_provider.gleam +++ b/src/lightbulb/providers/memory_provider.gleam @@ -1,23 +1,30 @@ /// Memory Provider is an in-memory data provider for Lightbulb. -/// +/// /// Warning: Data stored in this provider is not persistent and will be lost! -/// +/// /// This module can be used to quickly get up and running with Lightbulb /// without needing a database or external storage. It's also useful for testing /// and development purposes. It's important to note that this provider is not /// suitable for production use, as it does not persist data across restarts. -import birl -import birl/duration +import gleam/dict import gleam/erlang/process.{type Subject} import gleam/list import gleam/order.{Lt} import gleam/otp/actor.{type StartError} import gleam/pair import gleam/result +import gleam/time/duration +import gleam/time/timestamp import lightbulb/deployment.{type Deployment} +import lightbulb/errors.{NonceExpired, NonceInvalid, NonceReplayed} import lightbulb/jwk.{type Jwk} import lightbulb/nonce.{type Nonce, Nonce} -import lightbulb/providers/data_provider.{type DataProvider, DataProvider} +import lightbulb/providers/data_provider.{ + type DataProvider, type LoginContext, type ProviderError, DataProvider, + LaunchContextInvalid, LaunchContextNotFound, ProviderActiveJwkNotFound, + ProviderCreateNonceFailed, ProviderDeploymentNotFound, + ProviderRegistrationNotFound, +} import lightbulb/providers/memory_provider/tables.{type Table} import lightbulb/registration.{type Registration} import youid/uuid @@ -33,11 +40,20 @@ type State { jwks: List(Jwk), active_jwk_kid: String, nonces: List(Nonce), + used_nonces: List(String), + login_contexts: dict.Dict(String, LoginContext), registrations: Table(Registration), deployments: Table(Deployment), ) } +pub type NonceValidation { + ValidNonce + ExpiredNonce + ReplayedNonce + InvalidNonce +} + pub type Message { Shutdown GetActiveJwk(reply_with: Subject(Result(Jwk, Nil))) @@ -45,7 +61,11 @@ pub type Message { CreateJwk(jwk: Jwk) SetActiveJwk(kid: String) CreateNonce(reply_with: Subject(Result(Nonce, Nil))) - ValidateNonce(value: String, reply_with: Subject(Result(Nil, Nil))) + InsertNonce(nonce: Nonce) + ValidateNonce(value: String, reply_with: Subject(NonceValidation)) + SaveLoginContext(context: LoginContext, reply_with: Subject(Result(Nil, Nil))) + GetLoginContext(state: String, reply_with: Subject(Result(LoginContext, Nil))) + ConsumeLoginContext(state: String, reply_with: Subject(Result(Nil, Nil))) CleanupExpiredNonces CreateRegistration( registration: Registration, @@ -113,29 +133,102 @@ fn handle_message(state: State, message: Message) -> actor.Next(State, Message) CreateNonce(reply_with) -> { let nonce = - Nonce(uuid.v4_string(), birl.now() |> birl.add(duration.minutes(5))) + Nonce( + uuid.v4_string(), + timestamp.system_time() |> timestamp.add(duration.minutes(5)), + ) actor.send(reply_with, Ok(nonce)) actor.continue(State(..state, nonces: [nonce, ..state.nonces])) } + InsertNonce(nonce) -> { + actor.continue(State(..state, nonces: [nonce, ..state.nonces])) + } + ValidateNonce(value, reply_with) -> { - let result = list.find(state.nonces, fn(nonce) { nonce.nonce == value }) + case list.contains(state.used_nonces, value) { + True -> { + actor.send(reply_with, ReplayedNonce) + actor.continue(state) + } + + False -> { + let maybe_nonce = + list.find(state.nonces, fn(nonce) { nonce.nonce == value }) + + let nonces = + list.filter(state.nonces, fn(nonce) { nonce.nonce != value }) + + case maybe_nonce { + Ok(nonce) -> { + case + timestamp.compare(timestamp.system_time(), nonce.expires_at) + { + Lt -> { + actor.send(reply_with, ValidNonce) + actor.continue( + State(..state, nonces: nonces, used_nonces: [ + value, + ..state.used_nonces + ]), + ) + } + + _ -> { + actor.send(reply_with, ExpiredNonce) + actor.continue(State(..state, nonces: nonces)) + } + } + } + + Error(_) -> { + actor.send(reply_with, InvalidNonce) + actor.continue(state) + } + } + } + } + } - actor.send(reply_with, result |> result.map(fn(_) { Nil })) + SaveLoginContext(context, reply_with) -> { + actor.send(reply_with, Ok(Nil)) + actor.continue( + State( + ..state, + login_contexts: dict.insert( + state.login_contexts, + context.state, + context, + ), + ), + ) + } - // remove the nonce from the list so it can't be reused - let nonces = list.filter(state.nonces, fn(nonce) { nonce.nonce != value }) + GetLoginContext(state_key, reply_with) -> { + actor.send(reply_with, dict.get(state.login_contexts, state_key)) + actor.continue(state) + } - actor.continue(State(..state, nonces: nonces)) + ConsumeLoginContext(state_key, reply_with) -> { + actor.send( + reply_with, + dict.get(state.login_contexts, state_key) |> result.map(fn(_) { Nil }), + ) + actor.continue( + State( + ..state, + login_contexts: dict.delete(state.login_contexts, state_key), + ), + ) } CleanupExpiredNonces -> { - let now = birl.now() + let now = timestamp.system_time() let nonces = list.filter(state.nonces, fn(nonce) { - case birl.compare(now, nonce.expires_at) { + case timestamp.compare(now, nonce.expires_at) { Lt -> True _ -> False } @@ -216,6 +309,7 @@ fn handle_message(state: State, message: Message) -> actor.Next(State, Message) } } +/// Starts the in-memory provider actor process. pub fn start() -> Result(MemoryProvider, StartError) { let init = fn(self) { let state = @@ -224,6 +318,8 @@ pub fn start() -> Result(MemoryProvider, StartError) { jwks: [], active_jwk_kid: "", nonces: [], + used_nonces: [], + login_contexts: dict.new(), registrations: tables.new(), deployments: tables.new(), ) @@ -243,89 +339,147 @@ pub fn start() -> Result(MemoryProvider, StartError) { |> result.map(fn(started) { started.data }) } +/// Stops the in-memory provider actor process. pub fn cleanup(actor) { process.send(actor, Shutdown) } -pub fn data_provider(memory_provider) -> Result(DataProvider, String) { +/// Builds a `DataProvider` adapter backed by the memory provider actor. +pub fn data_provider(memory_provider) -> Result(DataProvider, ProviderError) { Ok( DataProvider( create_nonce: fn() { create_nonce(memory_provider) - |> result.replace_error("Failed to create nonce") + |> result.replace_error(ProviderCreateNonceFailed) }, validate_nonce: fn(nonce) { - validate_nonce(memory_provider, nonce) - |> result.replace_error("Failed to validate nonce") + case validate_nonce_detailed(memory_provider, nonce) { + ValidNonce -> Ok(Nil) + ExpiredNonce -> Error(NonceExpired) + ReplayedNonce -> Error(NonceReplayed) + InvalidNonce -> Error(NonceInvalid) + } + }, + save_login_context: fn(context) { + save_login_context(memory_provider, context) + |> result.replace_error(LaunchContextInvalid) + }, + get_login_context: fn(state_key) { + get_login_context(memory_provider, state_key) + |> result.replace_error(LaunchContextNotFound) + }, + consume_login_context: fn(state_key) { + consume_login_context(memory_provider, state_key) + |> result.replace_error(LaunchContextNotFound) }, get_registration: fn(issuer, client_id) { get_registration_by(memory_provider, issuer, client_id) |> result.map(pair.second) - |> result.replace_error("Failed to get registration") + |> result.replace_error(ProviderRegistrationNotFound) }, get_deployment: fn(issuer, client_id, deployment_id) { get_deployment(memory_provider, issuer, client_id, deployment_id) |> result.map(pair.second) - |> result.replace_error("Failed to get deployment") + |> result.replace_error(ProviderDeploymentNotFound) }, get_active_jwk: fn() { get_active_jwk(memory_provider) - |> result.replace_error("Failed to get active JWK") + |> result.replace_error(ProviderActiveJwkNotFound) }, ), ) } +/// Returns the active JWK. pub fn get_active_jwk(actor) { process.call(actor, call_timeout, GetActiveJwk) } +/// Returns all stored JWKs. pub fn get_all_jwks(actor) { process.call(actor, call_timeout, GetAllJwks) } +/// Inserts a JWK into storage. pub fn create_jwk(actor, jwk) { process.send(actor, CreateJwk(jwk)) } +/// Creates and stores a fresh nonce. pub fn create_nonce(actor) { process.call(actor, call_timeout, CreateNonce) } -pub fn validate_nonce(actor, value) { +/// Inserts a provided nonce into storage. +pub fn insert_nonce(actor, nonce: Nonce) { + process.send(actor, InsertNonce(nonce)) +} + +fn validate_nonce_detailed(actor, value: String) { process.call(actor, call_timeout, ValidateNonce(value, _)) } +/// Validates and consumes a nonce value. +pub fn validate_nonce(actor, value) { + case validate_nonce_detailed(actor, value) { + ValidNonce -> Ok(Nil) + _ -> Error(Nil) + } +} + +/// Saves login context under its `state` key. +pub fn save_login_context(actor, context: LoginContext) { + process.call(actor, call_timeout, SaveLoginContext(context, _)) +} + +/// Returns login context by `state` key. +pub fn get_login_context(actor, state_key: String) { + process.call(actor, call_timeout, GetLoginContext(state_key, _)) +} + +/// Consumes and removes login context by `state` key. +pub fn consume_login_context(actor, state_key: String) { + process.call(actor, call_timeout, ConsumeLoginContext(state_key, _)) +} + +/// Removes expired nonces from storage. pub fn cleanup_expired_nonces(actor) { process.send(actor, CleanupExpiredNonces) } +/// Creates a registration record. pub fn create_registration(actor, registration) { process.call(actor, call_timeout, CreateRegistration(registration, _)) } +/// Lists all registration records. pub fn list_registrations(actor) { process.call(actor, call_timeout, GetAllRegistrations) } +/// Returns a registration by numeric id. pub fn get_registration(actor, id) { process.call(actor, call_timeout, GetRegistration(id, _)) } +/// Returns a registration by issuer/client pair. pub fn get_registration_by(actor, issuer, client_id) { process.call(actor, call_timeout, GetRegistrationBy(issuer, client_id, _)) } +/// Deletes a registration by numeric id. pub fn delete_registration(actor, id) { process.send(actor, DeleteRegistration(id)) Ok(id) } +/// Creates a deployment record. pub fn create_deployment(actor, deployment) { process.call(actor, call_timeout, CreateDeployment(deployment, _)) } +/// Returns a deployment by issuer/client/deployment id. pub fn get_deployment(actor, issuer, client_id, deployment_id) { process.call(actor, call_timeout, GetDeployment( issuer, diff --git a/src/lightbulb/providers/memory_provider/tables.gleam b/src/lightbulb/providers/memory_provider/tables.gleam index 730ce08..e4539f3 100644 --- a/src/lightbulb/providers/memory_provider/tables.gleam +++ b/src/lightbulb/providers/memory_provider/tables.gleam @@ -10,36 +10,33 @@ pub type Table(a) { Table(incrementer: Id, records: List(Record(a))) } +/// Returns an empty table. pub fn new() { Table(1, []) } +/// Returns a record by id. pub fn get(table: Table(a), id: Id) { table.records |> list.filter(fn(record) { record.0 == id }) |> list.first() } +/// Returns the first record matching a selector function. pub fn get_by(table: Table(a), selector: fn(a) -> Bool) { table.records |> list.filter(fn(record) { selector(record.1) }) |> list.first() } -pub fn id(record: Record(a)) { - record.0 -} - -pub fn value(record: Record(a)) { - record.1 -} - +/// Inserts a record and returns the updated table and inserted tuple. pub fn insert(table: Table(a), record: a) { let new_record = #(table.incrementer, record) #(Table(table.incrementer + 1, [new_record, ..table.records]), new_record) } +/// Replaces a record by id. pub fn update(table: Table(a), id: Id, record: a) { let new_record = #(id, record) @@ -54,6 +51,7 @@ pub fn update(table: Table(a), id: Id, record: a) { Table(table.incrementer, records) } +/// Deletes a record by id. pub fn delete(table: Table(a), id: Id) { let records = list.filter(table.records, fn(existing_record) { existing_record.0 != id }) diff --git a/src/lightbulb/services/access_token.gleam b/src/lightbulb/services/access_token.gleam index 69e282e..0d9b406 100644 --- a/src/lightbulb/services/access_token.gleam +++ b/src/lightbulb/services/access_token.gleam @@ -1,56 +1,158 @@ -import birl -import birl/duration +//// # Access Token Service +//// +//// OAuth2 client-credentials helpers for obtaining LTI service access tokens. +//// +//// ## Example +//// +//// ```gleam +//// import gleam/result +//// import lightbulb/services/access_token +//// import lightbulb/services/access_token_cache +//// +//// fn fetch_ags_token( +//// cache: access_token_cache.TokenCache, +//// providers, +//// registration, +//// ) { +//// let scopes = [ +//// "https://purl.imsglobal.org/spec/lti-ags/scope/score", +//// ] +//// +//// // Cached flow: +//// use #(token, updated_cache) <- result.try( +//// access_token_cache.fetch_access_token_with_cache( +//// cache, +//// providers, +//// registration, +//// scopes, +//// ), +//// ) +//// +//// // Direct flow (without cache): +//// // use token <- result.try( +//// // access_token.fetch_access_token(providers, registration, scopes) +//// // ) +//// +//// Ok(#(token, updated_cache)) +//// } +//// ``` +import gleam/bool import gleam/dict import gleam/dynamic import gleam/dynamic/decode import gleam/http import gleam/http/request.{type Request} +import gleam/int import gleam/json import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import gleam/time/duration +import gleam/time/timestamp import gleam/uri -import youid/uuid import lightbulb/jose import lightbulb/jwk.{type Jwk} import lightbulb/providers.{type Providers} +import lightbulb/providers/data_provider import lightbulb/providers/http_provider.{type HttpProvider} import lightbulb/registration.{type Registration} import lightbulb/utils/logger +import youid/uuid + +const default_assertion_lifetime_seconds = 300 /// Represents an OAuth2 access token for LTI 1.3 services. pub type AccessToken { AccessToken(token: String, token_type: String, expires_in: Int, scope: String) } -/// Requests an OAuth2 access token. Returns Ok(AccessToken) on success, Error(_) otherwise. -/// -/// As parameters, expects: -/// 1. `providers`: A `Providers` instance that contains the HTTP provider and data provider. -/// 2. `registration`: A `Registration` instance used to fetch the access token endpoint and client ID. -/// 3. `scopes`: A list of scopes to request for the access token. -/// -/// Examples: -/// -/// ```gleam -/// fetch_access_token(providers, registration, ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"]) -/// // Ok(AccessToken("actual_access_token", "Bearer", 3600, "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")) -/// ``` +pub type AccessTokenError { + RequestBuildError(reason: String) + HttpTransportError(reason: String) + HttpStatusError(status: Int, body: String) + OAuthError( + error: String, + error_description: Option(String), + error_uri: Option(String), + ) + DecodeError(reason: String) + AssertionBuildError(reason: String) +} + +pub type AssertionOptions { + AssertionOptions(audience: Option(String), lifetime_seconds: Int) +} + +/// Returns default client-assertion options. +pub fn default_assertion_options() -> AssertionOptions { + AssertionOptions( + audience: None, + lifetime_seconds: default_assertion_lifetime_seconds, + ) +} + +fn unix_seconds(value: timestamp.Timestamp) -> Int { + timestamp.to_unix_seconds_and_nanoseconds(value).0 +} + +/// Converts access-token errors to stable human-readable messages. +pub fn access_token_error_to_string(error: AccessTokenError) -> String { + case error { + RequestBuildError(reason) -> "OAuth request build failed: " <> reason + HttpTransportError(reason) -> "OAuth request transport failed: " <> reason + HttpStatusError(status, _) -> + "OAuth token endpoint returned unexpected status: " + <> int.to_string(status) + OAuthError(error, error_description, _) -> + case error_description { + Some(description) -> + "OAuth token endpoint error (" <> error <> "): " <> description + None -> "OAuth token endpoint error: " <> error + } + DecodeError(reason) -> "OAuth token response decode failed: " <> reason + AssertionBuildError(reason) -> + "OAuth client assertion build failed: " <> reason + } +} + +/// Requests an OAuth2 access token. pub fn fetch_access_token( providers: Providers, registration: Registration, scopes: List(String), -) -> Result(AccessToken, String) { - use active_jwk <- result.try(providers.data.get_active_jwk()) - - let client_assertion = - create_client_assertion( - active_jwk, - registration.access_token_endpoint, - registration.client_id, - // TODO: should this be separate auth_server url for audience? - Some(registration.access_token_endpoint), - ) +) -> Result(AccessToken, AccessTokenError) { + fetch_access_token_with_options( + providers, + registration, + scopes, + default_assertion_options(), + ) +} + +/// Requests an OAuth2 access token using assertion options for audience and JWT lifetime. +pub fn fetch_access_token_with_options( + providers: Providers, + registration: Registration, + scopes: List(String), + assertion_options: AssertionOptions, +) -> Result(AccessToken, AccessTokenError) { + use active_jwk <- result.try( + providers.data.get_active_jwk() + |> result.map_error(fn(error) { + AssertionBuildError(data_provider.provider_error_to_string(error)) + }), + ) + + let AssertionOptions(audience: configured_audience, ..) = assertion_options + let resolved_audience = + audience(registration.access_token_endpoint, configured_audience) + + use client_assertion <- result.try(build_client_assertion( + active_jwk, + resolved_audience, + registration.client_id, + assertion_options, + )) request_token( providers.http, @@ -60,12 +162,72 @@ pub fn fetch_access_token( ) } +/// Builds and signs an OAuth client assertion JWT using the active JWK. +pub fn build_client_assertion( + active_jwk: Jwk, + assertion_audience: String, + client_id: String, + options: AssertionOptions, +) -> Result(String, AccessTokenError) { + use <- bool.guard( + when: string.trim(client_id) == "", + return: Error(AssertionBuildError("client_id is required")), + ) + + use <- bool.guard( + when: string.trim(active_jwk.kid) == "", + return: Error(AssertionBuildError("active JWK is missing kid")), + ) + + let AssertionOptions(lifetime_seconds: lifetime_seconds, ..) = options + use <- bool.guard( + when: lifetime_seconds <= 0, + return: Error(AssertionBuildError( + "assertion lifetime must be greater than zero", + )), + ) + + let #(_, jwk_map) = jwk.to_map(active_jwk) + let jti = uuid.v4_string() + let now = timestamp.system_time() |> unix_seconds + + let jwt = + dict.from_list([ + #("iss", dynamic.string(client_id)), + #("aud", dynamic.string(assertion_audience)), + #("sub", dynamic.string(client_id)), + #("iat", dynamic.int(now)), + #( + "exp", + timestamp.system_time() + |> timestamp.add(duration.seconds(lifetime_seconds)) + |> unix_seconds + |> dynamic.int(), + ), + #("jti", dynamic.string(jti)), + ]) + + let jws = + dict.from_list([ + #("alg", "RS256"), + #("typ", "JWT"), + #("kid", active_jwk.kid), + ]) + + let #(_, jose_jwt) = jose.sign_with_jws(jwk_map, jws, jwt) + let #(_, compact_signed) = jose.compact(jose_jwt) + + Ok(compact_signed) +} + fn request_token( http_provider: HttpProvider, url: String, client_assertion: String, scopes: List(String), -) -> Result(AccessToken, String) { +) -> Result(AccessToken, AccessTokenError) { + let requested_scope = string.join(scopes, " ") + let body = uri.query_to_string([ #("grant_type", "client_credentials"), @@ -74,12 +236,14 @@ fn request_token( "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ), #("client_assertion", client_assertion), - #("scope", string.join(scopes, " ")), + #("scope", requested_scope), ]) use req <- result.try( request.to(url) - |> result.replace_error("Error creating request for URL " <> url), + |> result.map_error(fn(_) { + RequestBuildError("invalid token endpoint URL: " <> url) + }), ) let req = @@ -92,87 +256,89 @@ fn request_token( case http_provider.send(req) { Ok(resp) -> case resp.status { - 200 | 201 -> decode_access_token(resp.body) + 200 | 201 -> decode_access_token(resp.body, requested_scope) _ -> { logger.error_meta("Error requesting access token", resp) - - Error("Error requesting access token") + decode_oauth_error(resp.status, resp.body) } } - e -> { - logger.error_meta("Error requesting access token", e) - Error("Error requesting access token") + Error(reason) -> { + logger.error_meta("Error requesting access token", reason) + Error(HttpTransportError(string.inspect(reason))) } } } -fn decode_access_token(body: String) -> Result(AccessToken, String) { +fn decode_access_token( + body: String, + requested_scope: String, +) -> Result(AccessToken, AccessTokenError) { let access_token_decoder = { use token <- decode.field("access_token", decode.string) use token_type <- decode.field("token_type", decode.string) - use expires_in <- decode.field("expires_in", decode.int) - use scope <- decode.field("scope", decode.string) - - decode.success(AccessToken( - token: token, - token_type: token_type, - expires_in: expires_in, - scope: scope, - )) + use expires_in <- decode.optional_field( + "expires_in", + None, + decode.optional(decode.int), + ) + use scope <- decode.optional_field( + "scope", + None, + decode.optional(decode.string), + ) + + decode.success( + AccessToken( + token: token, + token_type: token_type, + expires_in: case expires_in { + Some(value) -> value + None -> 0 + }, + scope: case scope { + Some(value) -> value + None -> requested_scope + }, + ), + ) } json.parse(body, access_token_decoder) |> result.map_error(fn(e) { - "Error decoding access token" <> string.inspect(e) + DecodeError("invalid token response body: " <> string.inspect(e)) }) } -fn create_client_assertion( - active_jwk: Jwk, - auth_token_url: String, - client_id: String, - auth_audience: Option(String), -) -> String { - // let #(_, jwk) = jose.generate_key(jose.Rsa(2048)) |> jose.to_map() - let #(_, jwk) = active_jwk |> jwk.to_map() - - let jti = uuid.v4_string() - - let jwt = - dict.from_list([ - #("iss", dynamic.string(client_id)), - #("aud", dynamic.string(audience(auth_token_url, auth_audience))), - #("sub", dynamic.string(client_id)), - #("iat", birl.now() |> birl.to_unix() |> dynamic.int()), - #( - "exp", - birl.now() - |> birl.add(duration.seconds(3600)) - |> birl.to_unix() - |> dynamic.int(), - ), - #("jti", dynamic.string(jti)), - ]) - - let jws = - dict.from_list([ - #("alg", "RS256"), - #("typ", "JWT"), - #("kid", active_jwk.kid), - ]) +fn decode_oauth_error(status: Int, body: String) -> Result(a, AccessTokenError) { + let oauth_error_decoder = { + use error <- decode.field("error", decode.string) + use error_description <- decode.optional_field( + "error_description", + None, + decode.optional(decode.string), + ) + use error_uri <- decode.optional_field( + "error_uri", + None, + decode.optional(decode.string), + ) - let #(_, jose_jwt) = jose.sign_with_jws(jwk, jws, jwt) - let #(_, compact_signed) = jose.compact(jose_jwt) + decode.success(#(error, error_description, error_uri)) + } - compact_signed + case json.parse(body, oauth_error_decoder) { + Ok(#(error, description, error_uri)) -> + Error(OAuthError(error, description, error_uri)) + Error(_) -> Error(HttpStatusError(status, body)) + } } fn audience(auth_token_url: String, auth_audience: Option(String)) -> String { case auth_audience { None -> auth_token_url Some("") -> auth_token_url - Some(audience) -> audience + Some(audience_value) -> audience_value } } diff --git a/src/lightbulb/services/access_token_cache.gleam b/src/lightbulb/services/access_token_cache.gleam new file mode 100644 index 0000000..64ecf30 --- /dev/null +++ b/src/lightbulb/services/access_token_cache.gleam @@ -0,0 +1,157 @@ +import gleam/bool +import gleam/dict +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gleam/time/timestamp +import lightbulb/providers.{type Providers} +import lightbulb/registration.{type Registration} +import lightbulb/services/access_token.{ + type AccessToken, type AccessTokenError, AccessToken, +} + +const default_refresh_window_seconds = 60 + +pub type TokenCacheKey { + TokenCacheKey(issuer: String, client_id: String, scopes_hash: String) +} + +pub type CachedToken { + CachedToken(token: AccessToken, expires_at_unix: Int) +} + +pub type TokenCache { + TokenCache( + entries: dict.Dict(TokenCacheKey, CachedToken), + refresh_window_seconds: Int, + ) +} + +/// Creates a token cache with the default refresh window. +pub fn new() -> TokenCache { + TokenCache( + entries: dict.new(), + refresh_window_seconds: default_refresh_window_seconds, + ) +} + +/// Creates a token cache with a custom refresh window in seconds. +pub fn with_refresh_window(refresh_window_seconds: Int) -> TokenCache { + let safe_refresh_window = int.max(refresh_window_seconds, 0) + TokenCache(entries: dict.new(), refresh_window_seconds: safe_refresh_window) +} + +fn unix_seconds(value: timestamp.Timestamp) -> Int { + timestamp.to_unix_seconds_and_nanoseconds(value).0 +} + +/// Builds a deterministic cache key from issuer, client, and scopes. +pub fn key( + issuer issuer: String, + client_id client_id: String, + scopes scopes: List(String), +) -> TokenCacheKey { + TokenCacheKey( + issuer: issuer, + client_id: client_id, + scopes_hash: normalize_scopes(scopes), + ) +} + +/// Returns a cached token when it is still fresh for the current time. +pub fn get( + cache: TokenCache, + cache_key: TokenCacheKey, + now_unix: Int, +) -> Option(CachedToken) { + let TokenCache( + refresh_window_seconds: refresh_window_seconds, + entries: entries, + ) = cache + + case dict.get(entries, cache_key) { + Ok(cached_token) -> + case token_is_fresh(cached_token, now_unix, refresh_window_seconds) { + True -> Some(cached_token) + False -> None + } + + _ -> None + } +} + +/// Stores a token in the cache for a computed key. +pub fn put( + cache: TokenCache, + cache_key: TokenCacheKey, + cached_token: CachedToken, +) -> TokenCache { + let TokenCache(entries: entries, ..) = cache + TokenCache(..cache, entries: dict.insert(entries, cache_key, cached_token)) +} + +/// Removes a cached token entry. +pub fn invalidate(cache: TokenCache, cache_key: TokenCacheKey) -> TokenCache { + let TokenCache(entries: entries, ..) = cache + TokenCache(..cache, entries: dict.delete(entries, cache_key)) +} + +/// Fetches a token from cache or the OAuth endpoint and returns updated cache state. +pub fn fetch_access_token_with_cache( + cache: TokenCache, + providers: Providers, + registration: Registration, + scopes: List(String), +) -> Result(#(AccessToken, TokenCache), AccessTokenError) { + let now_unix = timestamp.system_time() |> unix_seconds + let cache_key = key(registration.issuer, registration.client_id, scopes) + + case get(cache, cache_key, now_unix) { + Some(CachedToken(token: token, ..)) -> Ok(#(token, cache)) + None -> { + use token <- result.try(access_token.fetch_access_token( + providers, + registration, + scopes, + )) + + maybe_cache_token(cache, cache_key, token, now_unix) + |> result.map(fn(updated_cache) { #(token, updated_cache) }) + } + } +} + +fn maybe_cache_token( + cache: TokenCache, + cache_key: TokenCacheKey, + token: AccessToken, + now_unix: Int, +) -> Result(TokenCache, AccessTokenError) { + let AccessToken(expires_in: expires_in, ..) = token + + use <- bool.guard(when: expires_in <= 0, return: Ok(cache)) + + let cached_token = + CachedToken(token: token, expires_at_unix: now_unix + expires_in) + Ok(put(cache, cache_key, cached_token)) +} + +fn token_is_fresh( + cached_token: CachedToken, + now_unix: Int, + refresh_window_seconds: Int, +) -> Bool { + let CachedToken(expires_at_unix: expires_at_unix, ..) = cached_token + now_unix + refresh_window_seconds < expires_at_unix +} + +fn normalize_scopes(scopes: List(String)) -> String { + scopes + |> list.map(string.trim) + |> list.filter(fn(scope) { scope != "" }) + |> list.sort(fn(left, right) { string.compare(left, right) }) + |> list.unique() + |> string.join(" ") +} diff --git a/src/lightbulb/services/ags.gleam b/src/lightbulb/services/ags.gleam index cefd9be..d410fba 100644 --- a/src/lightbulb/services/ags.gleam +++ b/src/lightbulb/services/ags.gleam @@ -1,19 +1,71 @@ +//// # AGS Service +//// +//// Helpers for calling LTI Assignment and Grade Services (AGS) endpoints. +//// +//// ## Example +//// +//// ```gleam +//// import gleam/result +//// import lightbulb/services/ags +//// import lightbulb/services/ags/score +//// +//// fn post_grade( +//// http_provider, +//// claims, +//// access_token, +//// resource_id: String, +//// user_id: String, +//// ) { +//// use <- result.try(ags.require_can_write_line_items(claims)) +//// use <- result.try(ags.require_can_post_scores(claims)) +//// use service_url <- result.try(ags.get_line_items_service_url(claims)) +//// +//// use line_item <- result.try( +//// ags.fetch_or_create_line_item( +//// http_provider, +//// service_url, +//// resource_id, +//// fn() { 100.0 }, +//// "Gradebook Item", +//// access_token, +//// ), +//// ) +//// +//// let new_score = +//// score.Score( +//// score_given: 95.0, +//// score_maximum: 100.0, +//// timestamp: "2026-03-05T12:00:00Z", +//// user_id: user_id, +//// comment: "Great work", +//// activity_progress: "Completed", +//// grading_progress: "FullyGraded", +//// ) +//// +//// ags.post_score(http_provider, new_score, line_item, access_token) +//// } +//// ``` +import gleam/bool import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/http -import gleam/http/request.{type Request} +import gleam/http/request +import gleam/http/response +import gleam/int import gleam/json import gleam/list -import gleam/option.{type Option, None} +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import gleam/uri +import lightbulb/http/link_header import lightbulb/providers/http_provider.{type HttpProvider} import lightbulb/services/access_token.{ type AccessToken, set_authorization_header, } import lightbulb/services/ags/line_item.{type LineItem, LineItem} +import lightbulb/services/ags/result as ags_result import lightbulb/services/ags/score.{type Score} import lightbulb/utils/logger @@ -21,28 +73,89 @@ pub const lti_ags_claim_url = "https://purl.imsglobal.org/spec/lti-ags/claim/end pub const lineitem_scope_url = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" +pub const lineitem_readonly_scope_url = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly" + pub const result_readonly_scope_url = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly" pub const scores_scope_url = "https://purl.imsglobal.org/spec/lti-ags/scope/score" -/// Posts a score to the AGS service for a given line item. +pub type AgsError { + RequestInvalidUrl(url: String) + HttpTransport(reason: String) + HttpUnexpectedStatus(status: Int, body: String) + DecodeLineItem(reason: String) + DecodeResult(reason: String) + ScopeInsufficient(required_scope: String) + PaginationInvalidLinkHeader(reason: String) + InvalidLineItemId +} + +pub type LineItemsQuery { + LineItemsQuery( + resource_link_id: Option(String), + resource_id: Option(String), + tag: Option(String), + limit: Option(Int), + ) +} + +pub type ResultsQuery { + ResultsQuery(user_id: Option(String), limit: Option(Int)) +} + +pub type Paged(a) { + Paged(items: List(a), links: link_header.PageLinks) +} + +/// Returns an empty AGS line-items query with no filters. +pub fn default_line_items_query() -> LineItemsQuery { + LineItemsQuery( + resource_link_id: None, + resource_id: None, + tag: None, + limit: None, + ) +} + +/// Returns an empty AGS results query with no filters. +pub fn default_results_query() -> ResultsQuery { + ResultsQuery(user_id: None, limit: None) +} + +/// Converts AGS service errors to stable human-readable messages. +pub fn ags_error_to_string(error: AgsError) -> String { + case error { + RequestInvalidUrl(url) -> "invalid AGS URL: " <> url + HttpTransport(reason) -> "AGS transport error: " <> reason + HttpUnexpectedStatus(status, _) -> + "unexpected AGS HTTP status: " <> int.to_string(status) + DecodeLineItem(reason) -> "failed to decode line item payload: " <> reason + DecodeResult(reason) -> "failed to decode result payload: " <> reason + ScopeInsufficient(required_scope) -> + "launch does not include required AGS scope: " <> required_scope + PaginationInvalidLinkHeader(reason) -> + "invalid pagination Link header: " <> reason + InvalidLineItemId -> "line item id is required" + } +} + +/// Posts a score to the AGS `scores` endpoint for a line item. pub fn post_score( http_provider: HttpProvider, score: Score, line_item: LineItem, access_token: AccessToken, -) -> Result(String, String) { - use line_item_id <- result.try( - line_item.id |> option.to_result("Missing line item ID"), - ) +) -> Result(String, AgsError) { + let LineItem(id: id, ..) = line_item + + use line_item_id <- result.try(id |> option.to_result(InvalidLineItemId)) + let url = build_url_with_path(line_item_id, "scores") - let body = - score.to_json(score) - |> json.to_string() + let body = score.to_json(score) |> json.to_string() use req <- result.try( request.to(url) - |> result.replace_error("Error creating request for URL " <> url), + |> result.map_error(fn(_) { RequestInvalidUrl(url) }), ) let req = @@ -55,15 +168,65 @@ pub fn post_score( case http_provider.send(req) { Ok(res) -> case res.status { - 200 | 201 | 204 -> Ok(res.body) - _ -> Error("Unexpected status: " <> string.inspect(res)) + 200 | 201 | 202 | 204 -> Ok(res.body) + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) + } + + Error(reason) -> Error(HttpTransport(string.inspect(reason))) + } +} + +/// Creates a new AGS line item under the line-items container URL. +pub fn create_line_item( + http_provider: HttpProvider, + line_items_service_url: String, + resource_id: String, + score_maximum: Float, + label: String, + access_token: AccessToken, +) -> Result(LineItem, AgsError) { + let line_item = + LineItem( + id: None, + score_maximum: score_maximum, + label: label, + resource_id: resource_id, + resource_link_id: None, + tag: None, + start_date_time: None, + end_date_time: None, + grades_released: None, + ) + + let body = line_item.to_json(line_item) |> json.to_string() + + use req <- result.try( + request.to(line_items_service_url) + |> result.map_error(fn(_) { RequestInvalidUrl(line_items_service_url) }), + ) + + let req = + req + |> set_line_item_write_headers() + |> set_authorization_header(access_token) + |> request.set_method(http.Post) + |> request.set_body(body) + + case http_provider.send(req) { + Ok(res) -> + case res.status { + 200 | 201 -> + json.parse(res.body, line_item.decoder()) + |> result.map_error(fn(e) { DecodeLineItem(string.inspect(e)) }) + + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) } - e -> Error("Error posting score: " <> string.inspect(e)) + Error(reason) -> Error(HttpTransport(string.inspect(reason))) } } -/// Fetches an existing line item or creates a new one if it doesn't exist. +/// Fetches the first matching line item for `resource_id` or creates one. pub fn fetch_or_create_line_item( http_provider: HttpProvider, line_items_service_url: String, @@ -71,130 +234,312 @@ pub fn fetch_or_create_line_item( maximum_score_provider: fn() -> Float, label: String, access_token: AccessToken, -) -> Result(LineItem, String) { +) -> Result(LineItem, AgsError) { + let query = + LineItemsQuery( + ..default_line_items_query(), + resource_id: Some(resource_id), + limit: Some(1), + ) + + use page <- result.try(list_line_items( + http_provider, + line_items_service_url, + query, + access_token, + )) + + let Paged(items: items, ..) = page + + case items { + [] -> + create_line_item( + http_provider, + line_items_service_url, + resource_id, + maximum_score_provider(), + label, + access_token, + ) + + [raw_line_item, ..] -> Ok(raw_line_item) + } +} + +/// Fetches a single line item by its absolute line-item URL. +pub fn get_line_item( + http_provider: HttpProvider, + line_item_url: String, + access_token: AccessToken, +) -> Result(LineItem, AgsError) { + use req <- result.try( + request.to(line_item_url) + |> result.map_error(fn(_) { RequestInvalidUrl(line_item_url) }), + ) + + let req = + req + |> set_line_item_read_headers() + |> set_authorization_header(access_token) + |> request.set_method(http.Get) + + case http_provider.send(req) { + Ok(res) -> + case res.status { + 200 -> + json.parse(res.body, line_item.decoder()) + |> result.map_error(fn(e) { DecodeLineItem(string.inspect(e)) }) + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) + } + + Error(reason) -> Error(HttpTransport(string.inspect(reason))) + } +} + +/// Lists line items from AGS, applying query filters and pagination links. +pub fn list_line_items( + http_provider: HttpProvider, + line_items_service_url: String, + query: LineItemsQuery, + access_token: AccessToken, +) -> Result(Paged(LineItem), AgsError) { let url = - build_url_with_params(line_items_service_url, [ - #("resource_id", resource_id), - #("limit", "1"), - ]) + build_url_with_params( + line_items_service_url, + line_items_query_params(query), + ) use req <- result.try( request.to(url) - |> result.replace_error("Error creating request for URL " <> url), + |> result.map_error(fn(_) { RequestInvalidUrl(url) }), ) let req = req - |> set_line_items_headers() + |> set_line_items_list_headers() |> set_authorization_header(access_token) |> request.set_method(http.Get) case http_provider.send(req) { Ok(res) -> case res.status { - 200 | 201 -> { - use line_items <- result.try( + 200 -> { + use items <- result.try( json.parse(res.body, decode.list(line_item.decoder())) - |> result.map_error(fn(e) { - logger.error_meta("Error decoding line items", e) - "Error decoding line items" - }), + |> result.map_error(fn(e) { DecodeLineItem(string.inspect(e)) }), ) - case line_items { - [] -> - create_line_item( - http_provider, - line_items_service_url, - resource_id, - maximum_score_provider(), - label, - access_token, - ) - - [raw_line_item, ..] -> Ok(raw_line_item) - } + let links = page_links_from_response(res) + + Ok(Paged(items: items, links: links)) } - _ -> Error("Error retrieving existing line items") + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) } - _ -> Error("Error retrieving existing line items") + Error(reason) -> Error(HttpTransport(string.inspect(reason))) } } -/// Creates a new line item. -pub fn create_line_item( +/// Replaces an existing line item identified by `line_item.id`. +pub fn update_line_item( http_provider: HttpProvider, - line_items_service_url: String, - resource_id: String, - score_maximum: Float, - label: String, + line_item: LineItem, access_token: AccessToken, -) -> Result(LineItem, String) { - let line_item = - LineItem( - id: None, - score_maximum: score_maximum, - label: label, - resource_id: resource_id, - ) +) -> Result(LineItem, AgsError) { + let LineItem(id: id, ..) = line_item - let body = - line_item.to_json(line_item) - |> json.to_string() + use line_item_url <- result.try(id |> option.to_result(InvalidLineItemId)) use req <- result.try( - request.to(line_items_service_url) - |> result.replace_error( - "Error creating request for URL " <> line_items_service_url, - ), + request.to(line_item_url) + |> result.map_error(fn(_) { RequestInvalidUrl(line_item_url) }), ) let req = req - |> set_line_items_headers() + |> set_line_item_write_headers() |> set_authorization_header(access_token) - |> request.set_method(http.Post) - |> request.set_body(body) + |> request.set_method(http.Put) + |> request.set_body(line_item.to_json(line_item) |> json.to_string()) case http_provider.send(req) { Ok(res) -> case res.status { - 200 | 201 -> { + 200 | 201 -> json.parse(res.body, line_item.decoder()) - |> result.map_error(fn(e) { - "Error decoding line item: " <> string.inspect(e) - }) - } - e -> { - logger.error_meta("Error creating line item", res) - Error("Unexpected status: " <> string.inspect(e)) + |> result.map_error(fn(e) { DecodeLineItem(string.inspect(e)) }) + + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) + } + + Error(reason) -> Error(HttpTransport(string.inspect(reason))) + } +} + +/// Deletes a line item by URL. +pub fn delete_line_item( + http_provider: HttpProvider, + line_item_url: String, + access_token: AccessToken, +) -> Result(Nil, AgsError) { + use req <- result.try( + request.to(line_item_url) + |> result.map_error(fn(_) { RequestInvalidUrl(line_item_url) }), + ) + + let req = + req + |> set_authorization_header(access_token) + |> request.set_method(http.Delete) + + case http_provider.send(req) { + Ok(res) -> + case res.status { + 200 | 204 -> Ok(Nil) + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) + } + + Error(reason) -> Error(HttpTransport(string.inspect(reason))) + } +} + +/// Lists result records for a line item. +pub fn list_results( + http_provider: HttpProvider, + line_item_url: String, + query: ResultsQuery, + access_token: AccessToken, +) -> Result(Paged(ags_result.Result), AgsError) { + let url = + build_url_with_path(line_item_url, "results") + |> build_url_with_params(results_query_params(query)) + + use req <- result.try( + request.to(url) + |> result.map_error(fn(_) { RequestInvalidUrl(url) }), + ) + + let req = + req + |> set_results_list_headers() + |> set_authorization_header(access_token) + |> request.set_method(http.Get) + + case http_provider.send(req) { + Ok(res) -> + case res.status { + 200 -> { + use items <- result.try( + json.parse(res.body, decode.list(ags_result.decoder())) + |> result.map_error(fn(e) { DecodeResult(string.inspect(e)) }), + ) + + let links = page_links_from_response(res) + + Ok(Paged(items: items, links: links)) } + + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) } - e -> { - logger.error_meta("Error creating line item", e) - Error("Error creating new line item") - } + Error(reason) -> Error(HttpTransport(string.inspect(reason))) } } -/// Given a set of LTI claims, returns True if the grade passback -/// feature is available for the given LTI launch. +/// Given a set of LTI claims, returns True if score posting is available. pub fn grade_passback_available( lti_launch_claims: Dict(String, Dynamic), ) -> Bool { - { - use lti_ags_claim <- result.try( - get_lti_ags_claim(lti_launch_claims) |> result.replace_error(False), - ) + can_post_scores(lti_launch_claims) +} + +/// Returns True when the launch grants read access to line items. +pub fn can_read_line_items(lti_launch_claims: Dict(String, Dynamic)) -> Bool { + case get_lti_ags_claim(lti_launch_claims) { + Ok(claim) -> + list.contains(claim.scope, lineitem_scope_url) + || list.contains(claim.scope, lineitem_readonly_scope_url) + + Error(_) -> False + } +} + +/// Returns True when the launch grants write access to line items. +pub fn can_write_line_items(lti_launch_claims: Dict(String, Dynamic)) -> Bool { + case get_lti_ags_claim(lti_launch_claims) { + Ok(claim) -> list.contains(claim.scope, lineitem_scope_url) + Error(_) -> False + } +} + +/// Returns True when the launch grants score-posting access. +pub fn can_post_scores(lti_launch_claims: Dict(String, Dynamic)) -> Bool { + case get_lti_ags_claim(lti_launch_claims) { + Ok(claim) -> list.contains(claim.scope, scores_scope_url) + Error(_) -> False + } +} - Ok(list.contains(lti_ags_claim.scope, result_readonly_scope_url)) +/// Returns True when the launch grants read access to result records. +pub fn can_read_results(lti_launch_claims: Dict(String, Dynamic)) -> Bool { + case get_lti_ags_claim(lti_launch_claims) { + Ok(claim) -> list.contains(claim.scope, result_readonly_scope_url) + Error(_) -> False } - |> result.unwrap(False) } +/// Ensures line-item read scope is present. +pub fn require_can_read_line_items( + lti_launch_claims: Dict(String, Dynamic), +) -> Result(Nil, AgsError) { + use <- bool.guard( + when: !can_read_line_items(lti_launch_claims), + return: Error(ScopeInsufficient( + lineitem_scope_url <> " or " <> lineitem_readonly_scope_url, + )), + ) + + Ok(Nil) +} + +/// Ensures line-item write scope is present. +pub fn require_can_write_line_items( + lti_launch_claims: Dict(String, Dynamic), +) -> Result(Nil, AgsError) { + use <- bool.guard( + when: !can_write_line_items(lti_launch_claims), + return: Error(ScopeInsufficient(lineitem_scope_url)), + ) + + Ok(Nil) +} + +/// Ensures score-posting scope is present. +pub fn require_can_post_scores( + lti_launch_claims: Dict(String, Dynamic), +) -> Result(Nil, AgsError) { + use <- bool.guard( + when: !can_post_scores(lti_launch_claims), + return: Error(ScopeInsufficient(scores_scope_url)), + ) + + Ok(Nil) +} + +/// Ensures result-read scope is present. +pub fn require_can_read_results( + lti_launch_claims: Dict(String, Dynamic), +) -> Result(Nil, AgsError) { + use <- bool.guard( + when: !can_read_results(lti_launch_claims), + return: Error(ScopeInsufficient(result_readonly_scope_url)), + ) + + Ok(Nil) +} + +/// Returns the AGS line-items service URL from launch claims. pub fn get_line_items_service_url( lti_launch_claims: Dict(String, Dynamic), ) -> Result(String, String) { @@ -216,6 +561,7 @@ pub type AgsClaim { ) } +/// Decodes the AGS claim from LTI launch claims. pub fn get_lti_ags_claim( claims: Dict(String, Dynamic), ) -> Result(AgsClaim, String) { @@ -263,30 +609,61 @@ pub fn get_lti_ags_claim( }) } -fn set_line_items_headers(req: Request(String)) -> Request(String) { +fn set_line_items_list_headers( + req: request.Request(String), +) -> request.Request(String) { req - |> request.set_header( - "Content-Type", - "application/vnd.ims.lis.v2.lineitem+json", - ) |> request.set_header( "Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json", ) } -fn set_score_headers(req: Request(String)) -> Request(String) { +fn set_line_item_read_headers( + req: request.Request(String), +) -> request.Request(String) { + req + |> request.set_header("Accept", "application/vnd.ims.lis.v2.lineitem+json") +} + +fn set_line_item_write_headers( + req: request.Request(String), +) -> request.Request(String) { + req + |> request.set_header( + "Content-Type", + "application/vnd.ims.lis.v2.lineitem+json", + ) + |> request.set_header("Accept", "application/vnd.ims.lis.v2.lineitem+json") +} + +fn set_score_headers(req: request.Request(String)) -> request.Request(String) { req |> request.set_header("Content-Type", "application/vnd.ims.lis.v1.score+json") + |> request.set_header("Accept", "application/json") +} + +fn set_results_list_headers( + req: request.Request(String), +) -> request.Request(String) { + req |> request.set_header( "Accept", - "application/vnd.ims.lis.v2.lineitemcontainer+json", + "application/vnd.ims.lis.v2.resultcontainer+json", ) } fn build_url_with_path(base: String, path: String) -> String { case string.split(base, "?") { - [base, query] -> base <> "/" <> path <> "?" <> query + [base_path, query] -> + base_path + <> "/" + <> path + <> case query { + "" -> "" + _ -> "?" <> query + } + _ -> base <> "/" <> path } } @@ -295,9 +672,70 @@ fn build_url_with_params( base: String, params: List(#(String, String)), ) -> String { - case uri.parse_query(base) { - Ok(base_params) -> - base <> "?" <> uri.query_to_string(list.append(base_params, params)) - _ -> base <> "?" <> uri.query_to_string(params) + case params { + [] -> base + _ -> { + case string.split(base, "?") { + [base_path, query] -> { + let existing_params = case uri.parse_query(query) { + Ok(parsed) -> parsed + Error(_) -> [] + } + + base_path + <> "?" + <> uri.query_to_string(list.append(existing_params, params)) + } + + _ -> base <> "?" <> uri.query_to_string(params) + } + } + } +} + +fn line_items_query_params(query: LineItemsQuery) -> List(#(String, String)) { + let LineItemsQuery(resource_link_id, resource_id, tag, limit) = query + + [] + |> add_query_param("resource_link_id", resource_link_id) + |> add_query_param("resource_id", resource_id) + |> add_query_param("tag", tag) + |> add_query_param("limit", limit |> option.map(int.to_string)) +} + +fn results_query_params(query: ResultsQuery) -> List(#(String, String)) { + let ResultsQuery(user_id, limit) = query + + [] + |> add_query_param("user_id", user_id) + |> add_query_param("limit", limit |> option.map(int.to_string)) +} + +fn add_query_param( + params: List(#(String, String)), + key: String, + value: Option(String), +) -> List(#(String, String)) { + case value { + Some(v) -> [#(key, v), ..params] + None -> params + } +} + +fn page_links_from_response( + resp: response.Response(String), +) -> link_header.PageLinks { + case response.get_header(resp, "link") { + Ok(link) -> + case link_header.parse(link) { + Ok(links) -> links + Error(error) -> { + let reason = link_header.link_header_error_to_string(error) + logger.warn(ags_error_to_string(PaginationInvalidLinkHeader(reason))) + link_header.empty_page_links() + } + } + + Error(_) -> link_header.empty_page_links() } } diff --git a/src/lightbulb/services/ags/line_item.gleam b/src/lightbulb/services/ags/line_item.gleam index fb86682..c3f1104 100644 --- a/src/lightbulb/services/ags/line_item.gleam +++ b/src/lightbulb/services/ags/line_item.gleam @@ -1,6 +1,6 @@ import gleam/dynamic/decode import gleam/json.{type Json} -import gleam/option.{type Option} +import gleam/option.{type Option, None} /// Represents a line item in the AGS (Assignment Grading Service). pub type LineItem { @@ -9,12 +9,27 @@ pub type LineItem { score_maximum: Float, label: String, resource_id: String, + resource_link_id: Option(String), + tag: Option(String), + start_date_time: Option(String), + end_date_time: Option(String), + grades_released: Option(Bool), ) } /// Converts a `LineItem` to a JSON representation. pub fn to_json(line_item: LineItem) -> Json { - let LineItem(id, score_maximum, label, resource_id) = line_item + let LineItem( + id, + score_maximum, + label, + resource_id, + resource_link_id, + tag, + start_date_time, + end_date_time, + grades_released, + ) = line_item [ #("scoreMaximum", json.float(score_maximum)), @@ -22,6 +37,11 @@ pub fn to_json(line_item: LineItem) -> Json { #("resourceId", json.string(resource_id)), ] |> maybe_add(id, "id", json.string) + |> maybe_add(resource_link_id, "resourceLinkId", json.string) + |> maybe_add(tag, "tag", json.string) + |> maybe_add(start_date_time, "startDateTime", json.string) + |> maybe_add(end_date_time, "endDateTime", json.string) + |> maybe_add(grades_released, "gradesReleased", json.bool) |> json.object() } @@ -38,15 +58,41 @@ fn maybe_add( /// Decodes a JSON object into a `LineItem`. pub fn decoder() { - use id <- decode.field("id", decode.optional(decode.string)) + use id <- decode.optional_field("id", None, decode.optional(decode.string)) use score_maximum <- decode.field("scoreMaximum", decode.float) use label <- decode.field("label", decode.string) use resource_id <- decode.field("resourceId", decode.string) + use resource_link_id <- decode.optional_field( + "resourceLinkId", + None, + decode.optional(decode.string), + ) + use tag <- decode.optional_field("tag", None, decode.optional(decode.string)) + use start_date_time <- decode.optional_field( + "startDateTime", + None, + decode.optional(decode.string), + ) + use end_date_time <- decode.optional_field( + "endDateTime", + None, + decode.optional(decode.string), + ) + use grades_released <- decode.optional_field( + "gradesReleased", + None, + decode.optional(decode.bool), + ) decode.success(LineItem( id: id, score_maximum: score_maximum, label: label, resource_id: resource_id, + resource_link_id: resource_link_id, + tag: tag, + start_date_time: start_date_time, + end_date_time: end_date_time, + grades_released: grades_released, )) } diff --git a/src/lightbulb/services/ags/result.gleam b/src/lightbulb/services/ags/result.gleam new file mode 100644 index 0000000..8b649b5 --- /dev/null +++ b/src/lightbulb/services/ags/result.gleam @@ -0,0 +1,49 @@ +import gleam/dynamic/decode +import gleam/option.{type Option, None} + +/// Represents an AGS result item. +pub type Result { + Result( + id: Option(String), + user_id: String, + result_score: Option(Float), + result_maximum: Option(Float), + comment: Option(String), + score_of: Option(String), + ) +} + +/// Decodes an AGS result object. +pub fn decoder() { + use id <- decode.optional_field("id", None, decode.optional(decode.string)) + use user_id <- decode.field("userId", decode.string) + use result_score <- decode.optional_field( + "resultScore", + None, + decode.optional(decode.float), + ) + use result_maximum <- decode.optional_field( + "resultMaximum", + None, + decode.optional(decode.float), + ) + use comment <- decode.optional_field( + "comment", + None, + decode.optional(decode.string), + ) + use score_of <- decode.optional_field( + "scoreOf", + None, + decode.optional(decode.string), + ) + + decode.success(Result( + id: id, + user_id: user_id, + result_score: result_score, + result_maximum: result_maximum, + comment: comment, + score_of: score_of, + )) +} diff --git a/src/lightbulb/services/nrps.gleam b/src/lightbulb/services/nrps.gleam index c9ec41a..8c94b04 100644 --- a/src/lightbulb/services/nrps.gleam +++ b/src/lightbulb/services/nrps.gleam @@ -1,12 +1,56 @@ +//// # NRPS Service +//// +//// Helpers for calling LTI Names and Roles Provisioning Service (NRPS) endpoints. +//// +//// ## Example +//// +//// ```gleam +//// import gleam/option +//// import gleam/result +//// import lightbulb/services/nrps +//// +//// fn load_members(http_provider, claims, access_token) { +//// use <- result.try(nrps.require_can_read_memberships(claims)) +//// use service_url <- result.try(nrps.get_membership_service_url(claims)) +//// +//// let query = +//// nrps.MembershipsQuery( +//// ..nrps.default_memberships_query(), +//// role: option.Some("Instructor"), +//// limit: option.Some(50), +//// ) +//// +//// use page <- result.try( +//// nrps.fetch_memberships_with_options( +//// http_provider, +//// service_url, +//// query, +//// access_token, +//// ), +//// ) +//// +//// let nrps.MembershipsPage(members: members, links: links) = page +//// +//// // Follow `links.next` with `fetch_next_memberships_page/3` or +//// // `links.differences` with `fetch_differences_memberships_page/3`. +//// Ok(#(members, links)) +//// } +//// ``` +import gleam/bool import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/http -import gleam/http/request.{type Request} +import gleam/http/request +import gleam/http/response +import gleam/int import gleam/json -import gleam/option.{type Option} +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import gleam/uri +import lightbulb/http/link_header import lightbulb/providers/http_provider.{type HttpProvider} import lightbulb/services/access_token.{ type AccessToken, set_authorization_header, @@ -18,19 +62,90 @@ pub const nrps_claim_url = "https://purl.imsglobal.org/spec/lti-nrps/claim/names pub const context_membership_readonly_claim_url = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly" -/// Fetches memberships from the NRPS service using the provided HTTP provider, context memberships URL, and access token. +pub type NrpsError { + ClaimMissing + ClaimInvalid + ScopeInsufficient(required_scope: String) + RequestInvalidUrl(url: String) + HttpTransport(reason: String) + HttpUnexpectedStatus(status: Int, body: String) + DecodeMembershipContainer(reason: String) + DecodeMember(reason: String) + PaginationInvalidLinkHeader(reason: String) +} + +pub type NrpsClaim { + NrpsClaim(context_memberships_url: String, service_versions: List(String)) +} + +pub type MembershipsQuery { + MembershipsQuery( + role: Option(String), + limit: Option(Int), + rlid: Option(String), + url: Option(String), + ) +} + +pub type MembershipsPage { + MembershipsPage(members: List(Membership), links: link_header.PageLinks) +} + +/// Returns an empty NRPS memberships query with no filters. +pub fn default_memberships_query() -> MembershipsQuery { + MembershipsQuery(role: None, limit: None, rlid: None, url: None) +} + +/// Converts NRPS service errors to stable human-readable messages. +pub fn nrps_error_to_string(error: NrpsError) -> String { + case error { + ClaimMissing -> "missing LTI NRPS claim" + ClaimInvalid -> "invalid LTI NRPS claim" + ScopeInsufficient(required_scope) -> + "launch does not include required NRPS scope: " <> required_scope + RequestInvalidUrl(url) -> "invalid NRPS URL: " <> url + HttpTransport(reason) -> "NRPS transport error: " <> reason + HttpUnexpectedStatus(status, _) -> + "unexpected NRPS HTTP status: " <> int.to_string(status) + DecodeMembershipContainer(reason) -> + "failed to decode NRPS membership container: " <> reason + DecodeMember(reason) -> "failed to decode NRPS member: " <> reason + PaginationInvalidLinkHeader(reason) -> + "invalid pagination Link header: " <> reason + } +} + +/// Fetches memberships from the NRPS service. pub fn fetch_memberships( http_provider: HttpProvider, context_memberships_url: String, access_token: AccessToken, -) -> Result(List(Membership), String) { - logger.info("Fetching memberships from " <> context_memberships_url) +) -> Result(List(Membership), NrpsError) { + fetch_memberships_with_options( + http_provider, + context_memberships_url, + default_memberships_query(), + access_token, + ) + |> result.map(fn(page) { + let MembershipsPage(members: members, ..) = page + members + }) +} + +/// Fetches memberships using query options and returns page links. +pub fn fetch_memberships_with_options( + http_provider: HttpProvider, + context_memberships_url: String, + query: MembershipsQuery, + access_token: AccessToken, +) -> Result(MembershipsPage, NrpsError) { + let url = resolve_memberships_url(context_memberships_url, query) + logger.info("Fetching memberships from " <> url) use req <- result.try( - request.to(context_memberships_url <> "?limit=1000") - |> result.replace_error( - "Error creating request for URL " <> context_memberships_url, - ), + request.to(url) + |> result.map_error(fn(_) { RequestInvalidUrl(url) }), ) let req = @@ -43,95 +158,234 @@ pub fn fetch_memberships( Ok(res) -> case res.status { 200 | 201 -> { - let decoder = { - use members <- decode.field( - "members", - decode.list(membership.decoder()), - ) - - decode.success(members) - } - - use memberships <- result.try( - json.parse(res.body, decoder) - |> result.map_error(fn(e) { - logger.error_meta("Error decoding memberships", e) - - "Error decoding memberships" - }), - ) - - Ok(memberships) + use members <- result.try(members_from_response(res.body)) + let links = page_links_from_response(res) + Ok(MembershipsPage(members: members, links: links)) } - _ -> Error("Unexpected status: " <> string.inspect(res)) + _ -> Error(HttpUnexpectedStatus(res.status, res.body)) } - e -> Error("Error fetching memberships: " <> string.inspect(e)) + Error(reason) -> Error(HttpTransport(string.inspect(reason))) } } -/// Returns True if the NRPS service is available from the given launch claims. +/// Fetches the next page of memberships from a pagination URL. +pub fn fetch_next_memberships_page( + http_provider: HttpProvider, + next_url: String, + access_token: AccessToken, +) -> Result(MembershipsPage, NrpsError) { + fetch_memberships_with_options( + http_provider, + next_url, + MembershipsQuery(..default_memberships_query(), url: Some(next_url)), + access_token, + ) +} + +/// Fetches the differences page of memberships from a pagination URL. +pub fn fetch_differences_memberships_page( + http_provider: HttpProvider, + differences_url: String, + access_token: AccessToken, +) -> Result(MembershipsPage, NrpsError) { + fetch_memberships_with_options( + http_provider, + differences_url, + MembershipsQuery(..default_memberships_query(), url: Some(differences_url)), + access_token, + ) +} + +/// Returns True if the NRPS service and readonly scope are available. pub fn nrps_available(lti_launch_claims: Dict(String, Dynamic)) -> Bool { - case get_nrps_claim(lti_launch_claims) { - Ok(_nrps_claim) -> True + can_read_memberships(lti_launch_claims) +} + +/// Returns True when the NRPS claim exists and readonly scope is present. +pub fn can_read_memberships(lti_launch_claims: Dict(String, Dynamic)) -> Bool { + case get_nrps_claim_with_scope(lti_launch_claims) { + Ok(#(_claim, scope)) -> + list.contains(scope, context_membership_readonly_claim_url) + Error(_) -> False } } +/// Ensures the readonly NRPS scope is present. +pub fn require_can_read_memberships( + lti_launch_claims: Dict(String, Dynamic), +) -> Result(Nil, NrpsError) { + use <- bool.guard( + when: !can_read_memberships(lti_launch_claims), + return: Error(ScopeInsufficient(context_membership_readonly_claim_url)), + ) + + Ok(Nil) +} + /// Returns the context memberships URL from the LTI launch claims. pub fn get_membership_service_url( lti_launch_claims: Dict(String, Dynamic), -) -> Result(String, String) { - { - use nrps_claim <- result.try(get_nrps_claim(lti_launch_claims)) - - Ok(nrps_claim.context_memberships_url) - } +) -> Result(String, NrpsError) { + get_nrps_claim(lti_launch_claims) + |> result.map(fn(nrps_claim) { nrps_claim.context_memberships_url }) } -type NrpsClaim { - NrpsClaim( - context_memberships_url: String, - errors: Dict(String, Dynamic), - validation_context: Option(Dynamic), - ) +/// Decodes the NRPS claim from launch claims. +pub fn get_nrps_claim( + claims: Dict(String, Dynamic), +) -> Result(NrpsClaim, NrpsError) { + get_nrps_claim_with_scope(claims) + |> result.map(fn(tuple) { + let #(claim, _scope) = tuple + claim + }) } -fn get_nrps_claim(claims: Dict(String, Dynamic)) -> Result(NrpsClaim, String) { +fn get_nrps_claim_with_scope( + claims: Dict(String, Dynamic), +) -> Result(#(NrpsClaim, List(String)), NrpsError) { let nrps_claim_decoder = { use context_memberships_url <- decode.field( "context_memberships_url", decode.string, ) - use errors <- decode.field( - "errors", - decode.dict(decode.string, decode.dynamic), - ) - use validation_context <- decode.field( - "validation_context", - decode.optional(decode.dynamic), + use service_versions <- decode.field( + "service_versions", + decode.list(decode.string), ) + use scope <- decode.optional_field("scope", [], decode.list(decode.string)) - decode.success(NrpsClaim( - context_memberships_url: context_memberships_url, - errors: errors, - validation_context: validation_context, + decode.success(#( + NrpsClaim( + context_memberships_url: context_memberships_url, + service_versions: service_versions, + ), + scope, )) } dict.get(claims, nrps_claim_url) - |> result.replace_error("Missing LTI NRPS claim") + |> result.map_error(fn(_) { ClaimMissing }) |> result.try(fn(c) { decode.run(c, nrps_claim_decoder) - |> result.replace_error("Invalid LTI NRPS claim") + |> result.map_error(fn(_) { ClaimInvalid }) }) } -fn set_membership_headers(req: Request(String)) -> Request(String) { +fn set_membership_headers( + req: request.Request(String), +) -> request.Request(String) { req - |> request.set_header("Content-Type", "application/json") |> request.set_header( "Accept", "application/vnd.ims.lti-nrps.v2.membershipcontainer+json", ) } + +fn resolve_memberships_url(base: String, query: MembershipsQuery) -> String { + let MembershipsQuery(url: url, ..) = query + + case url { + Some(override) -> override + None -> build_url_with_params(base, query_params(query)) + } +} + +fn query_params(query: MembershipsQuery) -> List(#(String, String)) { + let MembershipsQuery(role, limit, rlid, _) = query + let string_limit = case limit { + Some(value) -> Some(int.to_string(value)) + None -> None + } + + [] + |> add_query_param("role", role) + |> add_query_param("limit", string_limit) + |> add_query_param("rlid", rlid) +} + +fn add_query_param( + params: List(#(String, String)), + key: String, + value: Option(String), +) -> List(#(String, String)) { + case value { + Some(v) -> [#(key, v), ..params] + None -> params + } +} + +fn build_url_with_params( + base: String, + params: List(#(String, String)), +) -> String { + case params { + [] -> base + _ -> { + case string.split(base, "?") { + [base_path, query] -> { + let existing_params = case uri.parse_query(query) { + Ok(parsed) -> parsed + Error(_) -> [] + } + + base_path + <> "?" + <> uri.query_to_string(list.append(existing_params, params)) + } + + _ -> base <> "?" <> uri.query_to_string(params) + } + } + } +} + +fn members_from_response(body: String) -> Result(List(Membership), NrpsError) { + let members_decoder = { + use members <- decode.field("members", decode.list(decode.dynamic)) + decode.success(members) + } + + use raw_members <- result.try( + json.parse(body, members_decoder) + |> result.map_error(fn(e) { DecodeMembershipContainer(string.inspect(e)) }), + ) + + decode_members(raw_members, []) +} + +fn decode_members( + raw_members: List(Dynamic), + acc: List(Membership), +) -> Result(List(Membership), NrpsError) { + case raw_members { + [] -> Ok(list.reverse(acc)) + [raw, ..rest] -> { + use member <- result.try( + decode.run(raw, membership.decoder()) + |> result.map_error(fn(e) { DecodeMember(string.inspect(e)) }), + ) + decode_members(rest, [member, ..acc]) + } + } +} + +fn page_links_from_response( + resp: response.Response(String), +) -> link_header.PageLinks { + case response.get_header(resp, "link") { + Ok(link) -> + case link_header.parse(link) { + Ok(links) -> links + Error(error) -> { + let reason = link_header.link_header_error_to_string(error) + logger.warn(nrps_error_to_string(PaginationInvalidLinkHeader(reason))) + link_header.empty_page_links() + } + } + + Error(_) -> link_header.empty_page_links() + } +} diff --git a/src/lightbulb/services/nrps/membership.gleam b/src/lightbulb/services/nrps/membership.gleam index 28f9c78..180fa08 100644 --- a/src/lightbulb/services/nrps/membership.gleam +++ b/src/lightbulb/services/nrps/membership.gleam @@ -1,38 +1,77 @@ import gleam/dynamic/decode +import gleam/option.{type Option, None} /// Represents a membership in the NRPS (Names and Roles Provisioning Service). pub type Membership { Membership( user_id: String, - status: String, - name: String, - given_name: String, - family_name: String, - email: String, roles: List(String), - picture: String, + status: Option(String), + name: Option(String), + given_name: Option(String), + family_name: Option(String), + middle_name: Option(String), + email: Option(String), + picture: Option(String), + lis_person_sourcedid: Option(String), ) } -/// Converts a `Membership` to a JSON representation. +/// Decodes an NRPS membership object. pub fn decoder() { use user_id <- decode.field("user_id", decode.string) - use status <- decode.field("status", decode.string) - use name <- decode.field("name", decode.string) - use given_name <- decode.field("given_name", decode.string) - use family_name <- decode.field("family_name", decode.string) - use email <- decode.field("email", decode.string) use roles <- decode.field("roles", decode.list(decode.string)) - use picture <- decode.field("picture", decode.string) + use status <- decode.optional_field( + "status", + None, + decode.optional(decode.string), + ) + use name <- decode.optional_field( + "name", + None, + decode.optional(decode.string), + ) + use given_name <- decode.optional_field( + "given_name", + None, + decode.optional(decode.string), + ) + use family_name <- decode.optional_field( + "family_name", + None, + decode.optional(decode.string), + ) + use middle_name <- decode.optional_field( + "middle_name", + None, + decode.optional(decode.string), + ) + use email <- decode.optional_field( + "email", + None, + decode.optional(decode.string), + ) + use picture <- decode.optional_field( + "picture", + None, + decode.optional(decode.string), + ) + use lis_person_sourcedid <- decode.optional_field( + "lis_person_sourcedid", + None, + decode.optional(decode.string), + ) decode.success(Membership( user_id: user_id, + roles: roles, status: status, name: name, given_name: given_name, family_name: family_name, + middle_name: middle_name, email: email, - roles: roles, picture: picture, + lis_person_sourcedid: lis_person_sourcedid, )) } diff --git a/src/lightbulb/tool.gleam b/src/lightbulb/tool.gleam index 47b1275..d5db2b4 100644 --- a/src/lightbulb/tool.gleam +++ b/src/lightbulb/tool.gleam @@ -1,5 +1,3 @@ -import birl -import birl/duration import gleam/bool import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} @@ -11,19 +9,60 @@ import gleam/json import gleam/list import gleam/order.{Gt, Lt} import gleam/result +import gleam/string +import gleam/time/duration +import gleam/time/timestamp import gleam/uri.{query_to_string} +import lightbulb/deep_linking +import lightbulb/deep_linking/settings +import lightbulb/errors.{ + type CoreError, AudienceInvalid, DeploymentNotFound, JwtExpired, + JwtInvalidClaim, JwtInvalidSignature, JwtNotYetValid, LaunchMissingParam, + LoginMissingParam, MessageTypeUnsupported, NonceInvalid, NonceValidationError, + RegistrationNotFound, StateInvalid, StateNotFound, TargetLinkUriMismatch, +} import lightbulb/jose.{type Claims, JoseJws, JoseJwt} -import lightbulb/providers/data_provider.{type DataProvider} +import lightbulb/providers/data_provider.{type DataProvider, LoginContext} import lightbulb/registration.{type Registration} import lightbulb/utils/logger import youid/uuid -const deployment_id_claim = "https://purl.imsglobal.org/spec/lti/claim/deployment_id" +pub const deployment_id_claim = "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + +pub const message_type_claim = "https://purl.imsglobal.org/spec/lti/claim/message_type" + +pub const version_claim = "https://purl.imsglobal.org/spec/lti/claim/version" + +pub const target_link_uri_claim = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri" + +pub const resource_link_claim = "https://purl.imsglobal.org/spec/lti/claim/resource_link" -const message_type_claim = "https://purl.imsglobal.org/spec/lti/claim/message_type" +pub const roles_claim = "https://purl.imsglobal.org/spec/lti/claim/roles" const lti_message_hint_claim = "lti_message_hint" +const login_context_ttl_minutes = 5 + +const timestamp_skew_seconds = 60 + +const inline_jwks_prefix = "inline_jwks:" + +fn required_login_param( + params: Dict(String, String), + key: String, +) -> Result(String, CoreError) { + dict.get(params, key) + |> result.map_error(fn(_) { LoginMissingParam(key) }) +} + +fn required_launch_param( + params: Dict(String, String), + key: String, +) -> Result(String, CoreError) { + dict.get(params, key) + |> result.map_error(fn(_) { LaunchMissingParam(key) }) +} + /// Builds an OIDC login response for the tool. This function will return a `state` and `redirect_url`. /// The `state` is an opaque string that will be used to verify the response from the /// OIDC provider. The `redirect_url` is the URL that the user will be redirected to @@ -32,18 +71,40 @@ const lti_message_hint_claim = "lti_message_hint" pub fn oidc_login( provider: DataProvider, params: Dict(String, String), -) -> Result(#(String, String), String) { - use _params <- result.try(validate_issuer_exists(params)) - use target_link_uri <- result.try( - dict.get(params, "target_link_uri") - |> result.replace_error("Missing target_link_uri"), +) -> Result(#(String, String), CoreError) { + use issuer <- result.try(required_login_param(params, "iss")) + use target_link_uri <- result.try(required_login_param( + params, + "target_link_uri", + )) + use login_hint <- result.try(required_login_param(params, "login_hint")) + use client_id <- result.try(required_login_param(params, "client_id")) + + use registration <- result.try( + provider.get_registration(issuer, client_id) + |> result.map_error(fn(_) { RegistrationNotFound }), ) - use login_hint <- result.try(validate_login_hint_exists(params)) - use registration <- result.try(validate_registration(provider, params)) - use client_id <- result.try(validate_client_id_exists(params)) let state = uuid.v4_string() - let assert Ok(nonce) = provider.create_nonce() + use nonce <- result.try( + provider.create_nonce() + |> result.map_error(fn(_) { NonceValidationError(NonceInvalid) }), + ) + + let login_context = + LoginContext( + state: state, + target_link_uri: target_link_uri, + issuer: issuer, + client_id: client_id, + expires_at: timestamp.system_time() + |> timestamp.add(duration.minutes(login_context_ttl_minutes)), + ) + + use _ <- result.try( + provider.save_login_context(login_context) + |> result.map_error(fn(_) { StateInvalid }), + ) let query_params = [ #("scope", "openid"), @@ -57,15 +118,12 @@ pub fn oidc_login( #("login_hint", login_hint), ] - // pass back LTI message hint if given let query_params = case dict.get(params, lti_message_hint_claim) { Ok(lti_message_hint) -> [ #("lti_message_hint", lti_message_hint), ..query_params ] - Error(_) -> - // if the hint is not present, we don't need to pass it back - query_params + Error(_) -> query_params } let redirect_url = @@ -74,131 +132,129 @@ pub fn oidc_login( Ok(#(state, redirect_url)) } -fn validate_issuer_exists( - params: Dict(String, String), -) -> Result(Dict(String, String), String) { - case dict.get(params, "iss") { - Ok(_issuer) -> Ok(params) - Error(_) -> Error("Missing issuer") - } -} - -fn validate_login_hint_exists( - params: Dict(String, String), -) -> Result(String, String) { - case dict.get(params, "login_hint") { - Ok(login_hint) -> Ok(login_hint) - Error(_) -> Error("Missing login hint") - } -} - -fn validate_registration( - provider: DataProvider, - params: Dict(String, String), -) -> Result(Registration, String) { - use issuer <- result.try( - dict.get(params, "iss") |> result.replace_error("Missing issuer"), - ) - use client_id <- result.try( - dict.get(params, "client_id") |> result.replace_error("Missing client_id"), - ) - - provider.get_registration(issuer, client_id) -} - -fn validate_client_id_exists( - params: Dict(String, String), -) -> Result(String, String) { - case dict.get(params, "client_id") { - Ok(client_id) -> Ok(client_id) - Error(_) -> Error("Missing client id") - } -} - -/// Validates the OIDC login response from the OIDC provider. This function will validate and unpack -/// the `id_token` and return claims as a map if the token is valid. The `state` parametrer is the -/// opaque string that was stored in a cookie during `oidc_login` step. +/// Validates the OIDC login response from the OIDC provider. pub fn validate_launch( provider: DataProvider, params: Dict(String, String), session_state: String, -) -> Result(Claims, String) { - use id_token <- result.try( - dict.get(params, "id_token") |> result.replace_error("Missing id_token"), +) -> Result(Claims, CoreError) { + use id_token <- result.try(required_launch_param(params, "id_token")) + use request_state <- result.try(required_launch_param(params, "state")) + + use <- bool.guard( + when: request_state != session_state, + return: Error(StateInvalid), ) - use _state <- result.try(validate_oidc_state(params, session_state)) + use registration <- result.try(peek_validate_registration(id_token, provider)) use claims <- result.try(verify_token(id_token, registration.keyset_url)) - use _claims <- result.try(validate_deployment( + + use _ <- result.try(validate_required_launch_claims(claims)) + use _ <- result.try(validate_deployment( claims, provider, registration.issuer, registration.client_id, )) - use _claims <- result.try(validate_timestamps(claims)) - use _claims <- result.try(validate_nonce(claims, provider)) - use _claims <- result.try(validate_message(claims)) - - Ok(claims) -} - -fn validate_oidc_state(params, session_state) { - use state <- result.try( - dict.get(params, "state") - |> result.replace_error("Missing state"), + use _ <- result.try(validate_timestamps(claims)) + use _ <- result.try(validate_nonce(claims, provider)) + use _ <- result.try(validate_login_context( + claims, + provider, + request_state, + registration, + )) + use _ <- result.try( + provider.consume_login_context(request_state) + |> result.map_error(fn(_) { StateNotFound }), ) - case state == session_state { - True -> Ok(state) - False -> Error("Invalid state") - } + Ok(claims) } fn peek_validate_registration( id_token: String, provider: DataProvider, -) -> Result(Registration, String) { - use #(issuer, client_id) <- result.try(peek_issuer_client_id(id_token)) +) -> Result(Registration, CoreError) { + let JoseJwt(claims: claims) = jose.peek(id_token) + + use issuer <- result.try(read_claim(claims, "iss", decode.string)) + use client_id <- result.try(resolve_client_id_from_claims(claims)) provider.get_registration(issuer, client_id) + |> result.map_error(fn(_) { RegistrationNotFound }) } -fn peek_issuer_client_id(id_token) { - use issuer <- result.try(peek_claim(id_token, "iss", decode.string)) - use client_id <- result.try(peek_claim(id_token, "aud", decode.string)) - - Ok(#(issuer, client_id)) +fn resolve_client_id_from_claims(claims: Claims) -> Result(String, CoreError) { + use audience <- result.try(read_claim(claims, "aud", decode.dynamic)) + use audiences <- result.try(parse_audiences(audience)) + + case audiences { + [] -> Error(AudienceInvalid) + [single] -> Ok(single) + [_, _, ..] -> { + use azp <- result.try( + read_claim(claims, "azp", decode.string) + |> result.map_error(fn(_) { AudienceInvalid }), + ) + + use <- bool.guard( + when: !list.any(audiences, fn(client) { client == azp }), + return: Error(AudienceInvalid), + ) + + Ok(azp) + } + } } -fn peek_claim(jwt_string: String, claim: String, decoder: Decoder(a)) { - case jose.peek(jwt_string) { - JoseJwt(claims: claims) -> { - case dict.get(claims, claim) { - Ok(value) -> - decode.run(value, decoder) |> result.replace_error("Invalid claim") - Error(_) -> Error("Missing claim") - } - } +fn parse_audiences(aud: Dynamic) -> Result(List(String), CoreError) { + case decode.run(aud, decode.string) { + Ok(single) -> Ok([single]) + + Error(_) -> + decode.run(aud, decode.list(decode.string)) + |> result.map_error(fn(_) { AudienceInvalid }) } } -fn peek_header_claim(jwt_string, header: String, decoder: Decoder(a)) { +fn read_claim( + claims: Claims, + claim: String, + decoder: Decoder(a), +) -> Result(a, CoreError) { + use value <- result.try( + dict.get(claims, claim) + |> result.map_error(fn(_) { JwtInvalidClaim }), + ) + + decode.run(value, decoder) + |> result.map_error(fn(_) { JwtInvalidClaim }) +} + +fn peek_header_claim( + jwt_string: String, + header: String, + decoder: Decoder(a), +) -> Result(a, CoreError) { case jose.peek_protected(jwt_string) { JoseJws(headers: headers, ..) -> { case dict.get(headers, header) { Ok(value) -> - decode.run(value, decoder) |> result.replace_error("Invalid header") - Error(_) -> Error("Missing header") + decode.run(value, decoder) + |> result.map_error(fn(_) { JwtInvalidClaim }) + + Error(_) -> Error(JwtInvalidClaim) } } } } -fn verify_token(id_token, keyset_url) { - use kid <- result.try( - peek_header_claim(id_token, "kid", decode.string) - |> result.replace_error("Missing kid"), - ) +fn verify_token( + id_token: String, + keyset_url: String, +) -> Result(Claims, CoreError) { + use kid <- result.try(peek_header_claim(id_token, "kid", decode.string)) use jwk <- result.try(fetch_jwk(keyset_url, kid)) case jose.verify(jose.from_map(jwk), id_token) { @@ -206,163 +262,245 @@ fn verify_token(id_token, keyset_url) { _ -> { logger.error_meta("Failed to verify id_token", id_token) - - Error("Failed to verify id_token") + Error(JwtInvalidSignature) } } } -fn fetch_jwk(keyset_url, kid) { - use req <- result.try( - request.to(keyset_url) |> result.replace_error("Invalid keyset URL"), - ) - - let req = - request.prepend_header(req, "accept", "application/vnd.hmrc.1.0+json") +fn fetch_jwk( + keyset_url: String, + kid: String, +) -> Result(Dict(String, String), CoreError) { + case string.starts_with(keyset_url, inline_jwks_prefix) { + True -> + string.replace(keyset_url, inline_jwks_prefix, "") + |> select_jwk_from_keyset(kid) + + False -> { + use req <- result.try( + request.to(keyset_url) + |> result.map_error(fn(_) { JwtInvalidClaim }), + ) + + let req = request.prepend_header(req, "accept", "application/json") + + use resp <- result.try( + httpc.send(req) + |> result.map_error(fn(_) { JwtInvalidSignature }), + ) + + case resp { + Response(status: 200, body: body, ..) -> + select_jwk_from_keyset(body, kid) + + Response(status: status, ..) -> { + logger.error_meta("Failed to fetch keyset", #(status, keyset_url)) + Error(JwtInvalidSignature) + } + } + } + } +} - // Send the HTTP request to the server - use resp <- result.try( - httpc.send(req) - |> result.replace_error("Failed to fetch keyset from " <> keyset_url), - ) +fn select_jwk_from_keyset( + body: String, + kid: String, +) -> Result(Dict(String, String), CoreError) { + let keyset_decoder = { + use keys <- decode.field( + "keys", + decode.list(decode.dict(decode.string, decode.string)), + ) + + decode.success(keys) + } - case resp { - Response(status: 200, body: body, ..) -> { - // Parse the JSON response - let keyset_decoder = { - use keys <- decode.field( - "keys", - decode.list(decode.dict(decode.string, decode.string)), - ) + case json.parse(from: body, using: keyset_decoder) { + Ok(keys) -> + list.find(keys, fn(key) { dict.get(key, "kid") == Ok(kid) }) + |> result.map_error(fn(_) { JwtInvalidSignature }) - decode.success(keys) - } + Error(_) -> { + logger.error_meta("Failed to parse keyset", body) + Error(JwtInvalidClaim) + } + } +} - case json.parse(from: body, using: keyset_decoder) { - Ok(keys) -> { - // Extract the JWK from the JSON response - list.find(keys, fn(key) { dict.get(key, "kid") == Ok(kid) }) - |> result.map_error(fn(_) { - logger.error_meta("Failed to find JWK with kid " <> kid, keys) +fn validate_required_launch_claims(claims: Claims) -> Result(String, CoreError) { + use message_type <- result.try(read_claim( + claims, + message_type_claim, + decode.string, + )) - "Failed to find JWK with kid" - }) - } - Error(_) -> { - logger.error_meta("Failed to parse keyset", #(keyset_url, body)) + case message_type { + "LtiResourceLinkRequest" -> + validate_resource_link_required_claims(claims) + |> result.map(fn(_) { message_type }) - Error("Failed to parse keyset") - } - } + message if message == deep_linking.lti_message_type_deep_linking_request -> { + settings.from_claims(claims) + |> result.map_error(fn(_) { JwtInvalidClaim }) + |> result.map(fn(_) { message_type }) } - Response(status: status, ..) -> { - logger.error_meta("Failed to fetch keyset", #(status, keyset_url)) - Error("Failed to fetch keyset") - } + _ -> Error(MessageTypeUnsupported) } } +fn validate_resource_link_required_claims( + claims: Claims, +) -> Result(Claims, CoreError) { + use version <- result.try(read_claim(claims, version_claim, decode.string)) + use <- bool.guard(when: version != "1.3.0", return: Error(JwtInvalidClaim)) + + use _ <- result.try(read_claim(claims, deployment_id_claim, decode.string)) + use _ <- result.try(read_claim(claims, target_link_uri_claim, decode.string)) + use resource_link <- result.try(read_claim( + claims, + resource_link_claim, + decode.dynamic, + )) + + use _ <- result.try( + decode.run(resource_link, { + use resource_link_id <- decode.field("id", decode.string) + decode.success(resource_link_id) + }) + |> result.map_error(fn(_) { JwtInvalidClaim }), + ) + + use roles <- result.try(read_claim( + claims, + roles_claim, + decode.list(decode.string), + )) + + use <- bool.guard(when: roles == [], return: Error(JwtInvalidClaim)) + + Ok(claims) +} + fn validate_deployment( claims: Claims, provider: DataProvider, issuer: String, client_id: String, -) { - use deployment_id <- result.try(get_claim( +) -> Result(Claims, CoreError) { + use deployment_id <- result.try(read_claim( claims, deployment_id_claim, decode.string, )) provider.get_deployment(issuer, client_id, deployment_id) + |> result.map(fn(_) { claims }) + |> result.map_error(fn(_) { DeploymentNotFound }) } -fn validate_timestamps(claims: Claims) { +fn validate_timestamps(claims: Claims) -> Result(Claims, CoreError) { use exp <- result.try( - get_claim(claims, "exp", decode.int) - |> result.map(birl.from_unix), + read_claim(claims, "exp", decode.int) + |> result.map(timestamp.from_unix_seconds), ) use iat <- result.try( - get_claim(claims, "iat", decode.int) |> result.map(birl.from_unix), + read_claim(claims, "iat", decode.int) + |> result.map(timestamp.from_unix_seconds), ) - let now = birl.now() - let buffer_sec = 2 - let a_few_seconds_ago = birl.subtract(now, duration.seconds(buffer_sec)) - let a_few_seconds_later = birl.add(now, duration.seconds(buffer_sec)) + let now = timestamp.system_time() + let lower_bound = + timestamp.add(now, duration.seconds(-timestamp_skew_seconds)) + let upper_bound = timestamp.add(now, duration.seconds(timestamp_skew_seconds)) use <- bool.guard( - birl.compare(exp, a_few_seconds_ago) == Lt, - Error("JWT exp is expired"), + when: timestamp.compare(exp, lower_bound) == Lt, + return: Error(JwtExpired), ) use <- bool.guard( - birl.compare(iat, a_few_seconds_later) == Gt, - Error("JWT iat is in the future"), + when: timestamp.compare(iat, upper_bound) == Gt, + return: Error(JwtInvalidClaim), ) - Ok(claims) -} + case dict.get(claims, "nbf") { + Ok(nbf_dynamic) -> { + use nbf <- result.try( + decode.run(nbf_dynamic, decode.int) + |> result.map(timestamp.from_unix_seconds) + |> result.map_error(fn(_) { JwtInvalidClaim }), + ) -fn validate_nonce(claims: Claims, provider: DataProvider) { - use nonce <- result.try(get_claim(claims, "nonce", decode.string)) + use <- bool.guard( + when: timestamp.compare(nbf, upper_bound) == Gt, + return: Error(JwtNotYetValid), + ) - case provider.validate_nonce(nonce) { - Ok(_) -> Ok(claims) - - Error(e) -> { - logger.error_meta("Failed to validate nonce: " <> e, claims) - - Error(e) + Ok(claims) } + + Error(_) -> Ok(claims) } } -fn validate_lti_resource_link_request_message( +fn validate_nonce( claims: Claims, -) -> Result(Dict(String, Dynamic), String) { - case get_claim(claims, message_type_claim, decode.string) { - Ok("LtiResourceLinkRequest") -> Ok(claims) - _ -> { - logger.error_meta("Invalid message type", #(claims, message_type_claim)) + provider: DataProvider, +) -> Result(Claims, CoreError) { + use nonce <- result.try(read_claim(claims, "nonce", decode.string)) - Error("Invalid message type") - } - } + provider.validate_nonce(nonce) + |> result.map_error(fn(error) { + logger.error_meta("Failed to validate nonce", #(error, nonce)) + NonceValidationError(error) + }) + |> result.map(fn(_) { claims }) } -const message_validators = [ - #("LtiResourceLinkRequest", validate_lti_resource_link_request_message), -] +fn validate_login_context( + claims: Claims, + provider: DataProvider, + request_state: String, + registration: Registration, +) -> Result(Claims, CoreError) { + use context <- result.try( + provider.get_login_context(request_state) + |> result.map_error(fn(_) { StateNotFound }), + ) + + let LoginContext( + target_link_uri: stored_target_link_uri, + issuer: issuer, + client_id: client_id, + expires_at: expires_at, + .., + ) = context + + use <- bool.guard( + when: timestamp.compare(expires_at, timestamp.system_time()) != Gt, + return: Error(StateNotFound), + ) + use <- bool.guard( + when: issuer != registration.issuer || client_id != registration.client_id, + return: Error(StateInvalid), + ) -fn validate_message(claims: Claims) { - use message_type <- result.try(get_claim( + use target_link_uri <- result.try(read_claim( claims, - message_type_claim, + target_link_uri_claim, decode.string, )) - use #(_, validator) <- result.try( - list.find(message_validators, fn(validator) { validator.0 == message_type }) - |> result.replace_error( - "No validator found for message type " <> message_type, - ), + use <- bool.guard( + when: target_link_uri != stored_target_link_uri, + return: Error(TargetLinkUriMismatch), ) - validator(claims) - |> result.replace_error("Invalid message type " <> message_type) + Ok(claims) } -fn get_claim( - claims: Claims, - claim: String, - decoder: Decoder(a), -) -> Result(a, String) { - dict.get(claims, claim) - |> result.map(fn(c) { - decode.run(c, decoder) - |> result.replace_error("Invalid claim " <> claim) - }) - |> result.replace_error("Missing claim " <> claim) - |> result.flatten() +/// Validates the LTI message type claim and required claims for that type. +pub fn validate_message_type(claims: Claims) -> Result(Claims, CoreError) { + validate_required_launch_claims(claims) + |> result.map(fn(_) { claims }) } diff --git a/src/lightbulb/utils/logger.gleam b/src/lightbulb/utils/logger.gleam index c91c16b..55ba578 100644 --- a/src/lightbulb/utils/logger.gleam +++ b/src/lightbulb/utils/logger.gleam @@ -1,5 +1,5 @@ -import gleam/dynamic.{type Dynamic} import gleam/string +import logging pub type Level { Emergency @@ -12,53 +12,76 @@ pub type Level { Debug } -/// Configure the Erlang logger to use the log level and output format that we -/// want, rather than the more verbose Erlang default format. -/// -@external(erlang, "lti_example_tool_ffi", "configure_logger_backend") -pub fn configure_backend() -> Nil +/// Configure the Erlang logger to use the logging package defaults. +pub fn configure_backend() -> Nil { + logging.configure() +} -@external(erlang, "logger", "log") -fn erlang_log(a: Level, b: String) -> Dynamic +/// Reduce logger output to emergency-only level. +/// Useful in tests to avoid expected-failure noise in output. +pub fn configure_quiet() -> Nil { + logging.set_level(logging.Emergency) +} +/// Logs a message at the provided level. pub fn log(level: Level, message: String) -> Nil { - erlang_log(level, message) - Nil + logging.log(to_logging_level(level), message) } +/// Logs a message and inspected metadata at the provided level. pub fn log_meta(level: Level, message: String, meta: a) -> Nil { - erlang_log(level, message <> "\n" <> string.inspect(meta)) - Nil + logging.log(to_logging_level(level), message <> "\n" <> string.inspect(meta)) } +/// Logs an info message. pub fn info(message: String) -> Nil { log(Info, message) } +/// Logs an info message with metadata. pub fn info_meta(message: String, meta: a) -> Nil { log_meta(Info, message, meta) } +/// Logs a warning message. pub fn warn(message: String) -> Nil { log(Warning, message) } +/// Logs a warning message with metadata. pub fn warn_meta(message: String, meta: a) -> Nil { log_meta(Warning, message, meta) } +/// Logs an error message. pub fn error(message: String) -> Nil { log(Error, message) } +/// Logs an error message with metadata. pub fn error_meta(message: String, meta: a) -> Nil { log_meta(Error, message, meta) } +/// Logs a debug message. pub fn debug(message: String) -> Nil { log(Debug, message) } +/// Logs a debug message with metadata. pub fn debug_meta(message: String, meta: a) -> Nil { log_meta(Debug, message, meta) } + +fn to_logging_level(level: Level) -> logging.LogLevel { + case level { + Emergency -> logging.Emergency + Alert -> logging.Alert + Critical -> logging.Critical + Error -> logging.Error + Warning -> logging.Warning + Notice -> logging.Notice + Info -> logging.Info + Debug -> logging.Debug + } +} diff --git a/src/lightbulb_ffi.erl b/src/lightbulb_ffi.erl deleted file mode 100644 index 5dc7518..0000000 --- a/src/lightbulb_ffi.erl +++ /dev/null @@ -1,17 +0,0 @@ --module(lightbulb_ffi). - --export([ - configure_logger_backend/0 -]). - -configure_logger_backend() -> - ok = logger:set_primary_config(level, info), - ok = logger:set_handler_config( - default, - formatter, - {logger_formatter, #{ - template => [level, ": ", msg, "\n"] - }} - ), - ok = logger:set_application_level(stdlib, notice), - nil. diff --git a/test/lightbulb/conformance/ags_certification_test.gleam b/test/lightbulb/conformance/ags_certification_test.gleam new file mode 100644 index 0000000..b48e39d --- /dev/null +++ b/test/lightbulb/conformance/ags_certification_test.gleam @@ -0,0 +1,33 @@ +import gleam/dict +import gleam/dynamic +import gleam/list +import gleeunit/should +import lightbulb/services/ags + +pub fn ags_fr_ags_04_missing_score_scope_rejected_test() { + let claims = ags_claim_with_scope([ags.lineitem_scope_url]) + + ags.require_can_post_scores(claims) + |> should.equal(Error(ags.ScopeInsufficient(ags.scores_scope_url))) +} + +pub fn ags_fr_ags_04_score_scope_allows_post_scores_test() { + let claims = ags_claim_with_scope([ags.scores_scope_url]) + + ags.require_can_post_scores(claims) + |> should.equal(Ok(Nil)) +} + +fn ags_claim_with_scope(scope: List(String)) { + dict.from_list([ + #( + ags.lti_ags_claim_url, + dynamic.properties([ + #( + dynamic.string("scope"), + dynamic.list(list.map(scope, dynamic.string)), + ), + ]), + ), + ]) +} diff --git a/test/lightbulb/conformance/core_certification_test.gleam b/test/lightbulb/conformance/core_certification_test.gleam new file mode 100644 index 0000000..3f5afcd --- /dev/null +++ b/test/lightbulb/conformance/core_certification_test.gleam @@ -0,0 +1,40 @@ +import gleam/dict +import gleam/dynamic +import gleeunit/should +import lightbulb/errors +import lightbulb/tool + +pub fn core_fr_core_01_missing_roles_rejected_test() { + let claims = + base_resource_link_claims() + |> dict.delete(tool.roles_claim) + + tool.validate_message_type(claims) + |> should.equal(Error(errors.JwtInvalidClaim)) +} + +pub fn core_fr_core_03_invalid_message_type_rejected_test() { + let claims = + base_resource_link_claims() + |> dict.insert(tool.message_type_claim, dynamic.string("LtiUnknownRequest")) + + tool.validate_message_type(claims) + |> should.equal(Error(errors.MessageTypeUnsupported)) +} + +fn base_resource_link_claims() { + dict.from_list([ + #(tool.version_claim, dynamic.string("1.3.0")), + #(tool.message_type_claim, dynamic.string("LtiResourceLinkRequest")), + #(tool.deployment_id_claim, dynamic.string("deployment-123")), + #( + tool.target_link_uri_claim, + dynamic.string("https://tool.example.com/launch"), + ), + #( + tool.resource_link_claim, + dynamic.properties([#(dynamic.string("id"), dynamic.string("resource-1"))]), + ), + #(tool.roles_claim, dynamic.list([dynamic.string("Instructor")])), + ]) +} diff --git a/test/lightbulb/conformance/deep_linking_certification_test.gleam b/test/lightbulb/conformance/deep_linking_certification_test.gleam new file mode 100644 index 0000000..5ef40cd --- /dev/null +++ b/test/lightbulb/conformance/deep_linking_certification_test.gleam @@ -0,0 +1,47 @@ +import gleam/dict +import gleam/dynamic +import gleam/option +import gleeunit/should +import lightbulb/deep_linking +import lightbulb/deep_linking/content_item +import lightbulb/deep_linking/settings +import lightbulb/errors +import lightbulb/jwk + +pub fn deep_linking_fr_dl_01_missing_settings_claim_rejected_test() { + deep_linking.get_deep_linking_settings(dict.new()) + |> should.equal(Error(errors.DeepLinkingClaimMissing)) +} + +pub fn deep_linking_fr_dl_02_invalid_item_type_rejected_test() { + let assert Ok(active_jwk) = jwk.generate() + + let request_claims = + dict.from_list([ + #("iss", dynamic.string("https://platform.example.com")), + #(deep_linking.claim_deployment_id, dynamic.string("deployment-123")), + ]) + + let settings = + settings.DeepLinkingSettings( + deep_link_return_url: "https://platform.example.com/deep_link_return", + accept_types: ["ltiResourceLink"], + accept_presentation_document_targets: ["iframe"], + accept_media_types: option.None, + accept_multiple: option.Some(True), + auto_create: option.None, + title: option.None, + text: option.None, + data: option.None, + accept_lineitem: option.None, + ) + + deep_linking.build_response_jwt( + request_claims, + settings, + [content_item.link("https://example.com", option.None, option.None)], + deep_linking.default_response_options(), + active_jwk, + ) + |> should.equal(Error(errors.DeepLinkingResponseInvalidItemType)) +} diff --git a/test/lightbulb/conformance/nrps_certification_test.gleam b/test/lightbulb/conformance/nrps_certification_test.gleam new file mode 100644 index 0000000..33e7a9f --- /dev/null +++ b/test/lightbulb/conformance/nrps_certification_test.gleam @@ -0,0 +1,44 @@ +import gleam/dict +import gleam/dynamic +import gleam/list +import gleeunit/should +import lightbulb/services/nrps + +pub fn nrps_fr_nrps_02_missing_scope_rejected_test() { + let claims = nrps_claim_with_scope([]) + + nrps.require_can_read_memberships(claims) + |> should.equal( + Error(nrps.ScopeInsufficient(nrps.context_membership_readonly_claim_url)), + ) +} + +pub fn nrps_fr_nrps_02_scope_present_allows_read_memberships_test() { + let claims = + nrps_claim_with_scope([nrps.context_membership_readonly_claim_url]) + + nrps.require_can_read_memberships(claims) + |> should.equal(Ok(Nil)) +} + +fn nrps_claim_with_scope(scope: List(String)) { + dict.from_list([ + #( + nrps.nrps_claim_url, + dynamic.properties([ + #( + dynamic.string("context_memberships_url"), + dynamic.string("https://lms.example.com/context/100/memberships"), + ), + #( + dynamic.string("service_versions"), + dynamic.list([dynamic.string("2.0")]), + ), + #( + dynamic.string("scope"), + dynamic.list(list.map(scope, dynamic.string)), + ), + ]), + ), + ]) +} diff --git a/test/lightbulb/core_test.gleam b/test/lightbulb/core_test.gleam new file mode 100644 index 0000000..f0b7fef --- /dev/null +++ b/test/lightbulb/core_test.gleam @@ -0,0 +1,624 @@ +import gleam/dict +import gleam/dynamic +import gleam/list +import gleam/option +import gleam/string +import gleam/time/duration +import gleam/time/timestamp +import gleam/uri +import gleeunit/should +import lightbulb/deep_linking +import lightbulb/deep_linking/settings +import lightbulb/deployment +import lightbulb/errors +import lightbulb/jose +import lightbulb/nonce +import lightbulb/providers/data_provider.{type DataProvider, LoginContext} +import lightbulb/providers/memory_provider +import lightbulb/registration +import lightbulb/tool + +type Fixture { + Fixture( + memory: memory_provider.MemoryProvider, + provider: DataProvider, + issuer: String, + client_id: String, + target_link_uri: String, + state: String, + nonce: String, + signing_jwk: dict.Dict(String, String), + kid: String, + ) +} + +fn unix_seconds(value: timestamp.Timestamp) -> Int { + timestamp.to_unix_seconds_and_nanoseconds(value).0 +} + +pub fn successful_resource_link_launch_test() { + let fixture = setup_fixture(option.None) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.be_ok() + + memory_provider.cleanup(fixture.memory) +} + +pub fn structured_error_to_string_conversion_test() { + errors.core_error_to_string(errors.NonceValidationError(errors.NonceExpired)) + |> should.equal("Nonce has expired.") + + errors.core_error_to_string(errors.LaunchMissingParam("state")) + |> should.equal("Missing required launch parameter: state.") +} + +pub fn missing_required_claim_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + dict.delete( + base_claims(fixture), + "https://purl.imsglobal.org/spec/lti/claim/roles", + ) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.JwtInvalidClaim)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn invalid_version_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + dict.insert( + base_claims(fixture), + "https://purl.imsglobal.org/spec/lti/claim/version", + dynamic.string("1.1.0"), + ) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.JwtInvalidClaim)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn unsupported_message_type_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + dict.insert( + base_claims(fixture), + "https://purl.imsglobal.org/spec/lti/claim/message_type", + dynamic.string("LtiUnknown"), + ) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.MessageTypeUnsupported)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn deep_linking_message_type_success_test() { + let fixture = setup_fixture(option.None) + + let claims = + base_claims(fixture) + |> dict.insert( + tool.message_type_claim, + dynamic.string(deep_linking.lti_message_type_deep_linking_request), + ) + |> dict.insert( + settings.claim_deep_linking_settings, + deep_linking_settings_claim(fixture.target_link_uri), + ) + + tool.validate_message_type(claims) + |> should.be_ok() + + memory_provider.cleanup(fixture.memory) +} + +pub fn deep_linking_message_type_missing_settings_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + base_claims(fixture) + |> dict.insert( + tool.message_type_claim, + dynamic.string(deep_linking.lti_message_type_deep_linking_request), + ) + |> dict.delete(settings.claim_deep_linking_settings) + + tool.validate_message_type(claims) + |> should.equal(Error(errors.JwtInvalidClaim)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn audience_single_success_test() { + let fixture = setup_fixture(option.None) + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.be_ok() + + memory_provider.cleanup(fixture.memory) +} + +pub fn audience_multi_with_azp_success_test() { + let fixture = setup_fixture(option.None) + + let claims = + base_claims_with_audience( + fixture, + dynamic.list([ + dynamic.string("some-other-client"), + dynamic.string(fixture.client_id), + ]), + ) + |> dict.insert("azp", dynamic.string(fixture.client_id)) + + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.be_ok() + + memory_provider.cleanup(fixture.memory) +} + +pub fn audience_multi_without_azp_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + base_claims_with_audience( + fixture, + dynamic.list([ + dynamic.string("some-other-client"), + dynamic.string(fixture.client_id), + ]), + ) + + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.AudienceInvalid)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn audience_azp_not_in_aud_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + base_claims_with_audience( + fixture, + dynamic.list([ + dynamic.string("some-other-client"), + dynamic.string(fixture.client_id), + ]), + ) + |> dict.insert("azp", dynamic.string("another-client")) + + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.AudienceInvalid)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn deployment_mismatch_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + dict.insert( + base_claims(fixture), + "https://purl.imsglobal.org/spec/lti/claim/deployment_id", + dynamic.string("wrong-deployment"), + ) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.DeploymentNotFound)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn state_mismatch_failure_test() { + let fixture = setup_fixture(option.None) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + "different-session-state", + ) + |> should.equal(Error(errors.StateInvalid)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn missing_state_context_failure_test() { + let fixture = setup_fixture(option.None) + + let assert Ok(Nil) = fixture.provider.consume_login_context(fixture.state) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.StateNotFound)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn expired_state_context_failure_test() { + let fixture = setup_fixture(option.None) + + let assert Ok(Nil) = + fixture.provider.save_login_context(LoginContext( + state: fixture.state, + target_link_uri: fixture.target_link_uri, + issuer: fixture.issuer, + client_id: fixture.client_id, + expires_at: timestamp.system_time() + |> timestamp.add(duration.minutes(-10)), + )) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.StateNotFound)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn target_link_uri_mismatch_failure_test() { + let fixture = setup_fixture(option.None) + + let claims = + dict.insert( + base_claims(fixture), + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri", + dynamic.string("https://tool.example.com/other"), + ) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.TargetLinkUriMismatch)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn expired_nonce_failure_test() { + let fixture = setup_fixture(option.None) + + let expired_nonce = "expired-nonce" + memory_provider.insert_nonce( + fixture.memory, + nonce.Nonce( + expired_nonce, + timestamp.system_time() + |> timestamp.add(duration.minutes(-10)), + ), + ) + + let claims = + dict.insert(base_claims(fixture), "nonce", dynamic.string(expired_nonce)) + let id_token = sign_token(fixture, claims, fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.NonceValidationError(errors.NonceExpired))) + + memory_provider.cleanup(fixture.memory) +} + +pub fn replayed_nonce_failure_test() { + let fixture = setup_fixture(option.None) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.be_ok() + + let assert Ok(Nil) = + fixture.provider.save_login_context(LoginContext( + state: fixture.state, + target_link_uri: fixture.target_link_uri, + issuer: fixture.issuer, + client_id: fixture.client_id, + expires_at: timestamp.system_time() + |> timestamp.add(duration.minutes(5)), + )) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.NonceValidationError(errors.NonceReplayed))) + + memory_provider.cleanup(fixture.memory) +} + +pub fn unknown_kid_failure_test() { + let fixture = setup_fixture(option.None) + + let id_token = sign_token(fixture, base_claims(fixture), "unknown-kid") + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.JwtInvalidSignature)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn invalid_signature_failure_test() { + let fixture = setup_fixture(option.None) + let #(_, attacker_jwk) = jose.generate_key(jose.Rsa(2048)) |> jose.to_map() + + let id_token = + sign_token_with_jwk(attacker_jwk, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.JwtInvalidSignature)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn malformed_keyset_failure_test() { + let fixture = setup_fixture(option.Some("inline_jwks:not-json")) + + let id_token = sign_token(fixture, base_claims(fixture), fixture.kid) + + tool.validate_launch( + fixture.provider, + dict.from_list([#("id_token", id_token), #("state", fixture.state)]), + fixture.state, + ) + |> should.equal(Error(errors.JwtInvalidClaim)) + + memory_provider.cleanup(fixture.memory) +} + +fn setup_fixture(keyset_url_override: option.Option(String)) -> Fixture { + let issuer = "https://platform.example.com" + let client_id = "tool-client" + let target_link_uri = "https://tool.example.com/launch" + let kid = "platform-kid" + + let assert Ok(memory) = memory_provider.start() + let assert Ok(provider) = memory_provider.data_provider(memory) + + let #(_, signing_jwk) = jose.generate_key(jose.Rsa(2048)) |> jose.to_map() + let #(_, public_jwk_base) = + signing_jwk + |> jose.from_map() + |> jose.to_public() + |> jose.to_map() + + let public_jwk = dict.insert(public_jwk_base, "kid", kid) + + let keyset_url = case keyset_url_override { + option.Some(override) -> override + option.None -> "inline_jwks:" <> build_keyset_json(public_jwk) + } + + let registration = + registration.Registration( + name: "test-platform", + issuer: issuer, + client_id: client_id, + auth_endpoint: "https://platform.example.com/auth", + access_token_endpoint: "https://platform.example.com/token", + keyset_url: keyset_url, + ) + + let assert Ok(#(registration_id, _)) = + memory_provider.create_registration(memory, registration) + + let assert Ok(#(_, _)) = + memory_provider.create_deployment( + memory, + deployment.Deployment("deployment-1", registration_id), + ) + + let login_params = + dict.from_list([ + #("iss", issuer), + #("client_id", client_id), + #("login_hint", "student-1"), + #("target_link_uri", target_link_uri), + ]) + + let assert Ok(#(state, redirect_url)) = + tool.oidc_login(provider, login_params) + let nonce = redirect_query_param(redirect_url, "nonce") + + Fixture( + memory, + provider, + issuer, + client_id, + target_link_uri, + state, + nonce, + signing_jwk, + kid, + ) +} + +fn build_keyset_json(jwk: dict.Dict(String, String)) -> String { + let key_json_parts = + jwk + |> dict.to_list() + |> list.map(fn(entry) { + let #(key, value) = entry + "\"" <> key <> "\":\"" <> value <> "\"" + }) + + "{\"keys\":[{" <> string.join(key_json_parts, ",") <> "}]}" +} + +fn redirect_query_param(url: String, key: String) -> String { + let assert [_, query] = string.split(url, "?") + let assert Ok(params) = uri.parse_query(query) + let assert Ok(value) = list.key_find(params, key) + + value +} + +fn sign_token( + fixture: Fixture, + claims: dict.Dict(String, dynamic.Dynamic), + kid: String, +) { + sign_token_with_jwk(fixture.signing_jwk, claims, kid) +} + +fn sign_token_with_jwk( + jwk: dict.Dict(String, String), + claims: dict.Dict(String, dynamic.Dynamic), + kid: String, +) -> String { + let jws = dict.from_list([#("alg", "RS256"), #("typ", "JWT"), #("kid", kid)]) + + let #(_, jose_jwt) = jose.sign_with_jws(jwk, jws, claims) + let #(_, compact_signed) = jose.compact(jose_jwt) + + compact_signed +} + +fn base_claims(fixture: Fixture) { + base_claims_with_audience(fixture, dynamic.string(fixture.client_id)) +} + +fn base_claims_with_audience(fixture: Fixture, aud: dynamic.Dynamic) { + let now = timestamp.system_time() + let exp = + timestamp.add(now, duration.minutes(5)) + |> unix_seconds + let iat = unix_seconds(now) + + dict.from_list([ + #("iss", dynamic.string(fixture.issuer)), + #("aud", aud), + #("exp", dynamic.int(exp)), + #("iat", dynamic.int(iat)), + #("nonce", dynamic.string(fixture.nonce)), + #( + "https://purl.imsglobal.org/spec/lti/claim/message_type", + dynamic.string("LtiResourceLinkRequest"), + ), + #( + "https://purl.imsglobal.org/spec/lti/claim/version", + dynamic.string("1.3.0"), + ), + #( + "https://purl.imsglobal.org/spec/lti/claim/deployment_id", + dynamic.string("deployment-1"), + ), + #( + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri", + dynamic.string(fixture.target_link_uri), + ), + #( + "https://purl.imsglobal.org/spec/lti/claim/resource_link", + dynamic.properties([ + #(dynamic.string("id"), dynamic.string("resource-1")), + ]), + ), + #( + "https://purl.imsglobal.org/spec/lti/claim/roles", + dynamic.list([ + dynamic.string( + "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner", + ), + ]), + ), + ]) +} + +fn deep_linking_settings_claim(return_url: String) -> dynamic.Dynamic { + dynamic.properties([ + #(dynamic.string("deep_link_return_url"), dynamic.string(return_url)), + #( + dynamic.string("accept_types"), + dynamic.list([dynamic.string("ltiResourceLink"), dynamic.string("link")]), + ), + #( + dynamic.string("accept_presentation_document_targets"), + dynamic.list([dynamic.string("iframe")]), + ), + ]) +} diff --git a/test/lightbulb/deep_linking_test.gleam b/test/lightbulb/deep_linking_test.gleam new file mode 100644 index 0000000..45d4fdd --- /dev/null +++ b/test/lightbulb/deep_linking_test.gleam @@ -0,0 +1,339 @@ +import gleam/dict +import gleam/dynamic +import gleam/dynamic/decode +import gleam/option +import gleam/result +import gleam/string +import gleeunit/should +import lightbulb/deep_linking +import lightbulb/deep_linking/content_item +import lightbulb/deep_linking/settings.{DeepLinkingSettings} +import lightbulb/errors +import lightbulb/jose +import lightbulb/jwk + +pub fn get_deep_linking_settings_test() { + let claims = + dict.from_list([ + #(settings.claim_deep_linking_settings, dynamic_settings_claim()), + ]) + + let assert Ok(settings) = deep_linking.get_deep_linking_settings(claims) + + settings.deep_link_return_url + |> should.equal("https://platform.example.com/deep_link_return") + + settings.accept_types + |> should.equal(["ltiResourceLink", "link"]) + + settings.accept_multiple + |> should.equal(option.Some(True)) +} + +pub fn get_deep_linking_settings_missing_claim_test() { + deep_linking.get_deep_linking_settings(dict.new()) + |> should.equal(Error(errors.DeepLinkingClaimMissing)) +} + +pub fn build_response_jwt_test() { + let assert Ok(active_jwk) = jwk.generate() + + let request_claims = + dict.from_list([ + #("iss", dynamic.string("https://platform.example.com")), + #(deep_linking.claim_deployment_id, dynamic.string("deployment-123")), + ]) + + let assert Ok(settings) = + deep_linking.get_deep_linking_settings( + dict.from_list([ + #(settings.claim_deep_linking_settings, dynamic_settings_claim()), + ]), + ) + + let items = [ + content_item.lti_resource_link( + option.Some("https://tool.example.com/launch/abc"), + option.Some("Unit 1"), + option.Some("Open Unit 1"), + option.Some(dict.from_list([#("chapter", "1")])), + option.Some(content_item.LineItem( + score_maximum: 100.0, + resource_id: option.Some("resource-1"), + tag: option.Some("graded"), + label: option.Some("Unit 1 Grade"), + grades_released: option.Some(True), + )), + ), + ] + + let options = + deep_linking.DeepLinkingResponseOptions( + msg: option.Some("Ready"), + log: option.None, + errormsg: option.None, + errorlog: option.None, + ttl_seconds: 60, + ) + + let assert Ok(jwt) = + deep_linking.build_response_jwt( + request_claims, + settings, + items, + options, + active_jwk, + ) + + let #(_, public_jwk_map) = active_jwk |> jwk.to_map() + let #(verified, verified_jwt, verified_jws) = + jose.verify(jose.to_public(jose.from_map(public_jwk_map)), jwt) + + verified + |> should.be_true() + + decode_claim_string(verified_jwt.claims, "aud") + |> should.equal(Ok("https://platform.example.com")) + + decode_claim_string(verified_jwt.claims, deep_linking.claim_message_type) + |> should.equal(Ok(deep_linking.lti_message_type_deep_linking_response)) + + decode_claim_string(verified_jwt.claims, deep_linking.claim_deployment_id) + |> should.equal(Ok("deployment-123")) + + decode_claim_string(verified_jwt.claims, deep_linking.claim_data) + |> should.equal(Ok("opaque-platform-data")) + + decode_claim_string(verified_jwt.claims, deep_linking.claim_msg) + |> should.equal(Ok("Ready")) + + decode_claim_string(verified_jws.headers, "kid") + |> should.equal(Ok(active_jwk.kid)) +} + +pub fn build_response_jwt_rejects_invalid_item_type_test() { + let assert Ok(active_jwk) = jwk.generate() + + let request_claims = + dict.from_list([ + #("iss", dynamic.string("https://platform.example.com")), + #(deep_linking.claim_deployment_id, dynamic.string("deployment-123")), + ]) + + let settings = + DeepLinkingSettings( + deep_link_return_url: "https://platform.example.com/deep_link_return", + accept_types: ["ltiResourceLink"], + accept_presentation_document_targets: ["iframe"], + accept_media_types: option.None, + accept_multiple: option.Some(True), + auto_create: option.None, + title: option.None, + text: option.None, + data: option.None, + accept_lineitem: option.None, + ) + + let result = + deep_linking.build_response_jwt( + request_claims, + settings, + [content_item.link("https://example.com", option.None, option.None)], + deep_linking.default_response_options(), + active_jwk, + ) + + result + |> should.equal(Error(errors.DeepLinkingResponseInvalidItemType)) +} + +pub fn validate_items_accepts_allowed_type_and_count_test() { + let settings = + DeepLinkingSettings( + deep_link_return_url: "https://platform.example.com/deep_link_return", + accept_types: ["link"], + accept_presentation_document_targets: ["iframe"], + accept_media_types: option.None, + accept_multiple: option.Some(False), + auto_create: option.None, + title: option.None, + text: option.None, + data: option.None, + accept_lineitem: option.None, + ) + + content_item.validate_items(settings, [ + content_item.link( + "https://example.com/resource-1", + option.None, + option.None, + ), + ]) + |> should.equal(Ok(Nil)) +} + +pub fn validate_items_rejects_multiple_when_not_allowed_test() { + let settings = + DeepLinkingSettings( + deep_link_return_url: "https://platform.example.com/deep_link_return", + accept_types: ["link"], + accept_presentation_document_targets: ["iframe"], + accept_media_types: option.None, + accept_multiple: option.Some(False), + auto_create: option.None, + title: option.None, + text: option.None, + data: option.None, + accept_lineitem: option.None, + ) + + content_item.validate_items(settings, [ + content_item.link( + "https://example.com/resource-1", + option.None, + option.None, + ), + content_item.link( + "https://example.com/resource-2", + option.None, + option.None, + ), + ]) + |> should.equal(Error(errors.DeepLinkingResponseMultipleNotAllowed)) +} + +pub fn validate_items_rejects_line_item_when_not_allowed_test() { + let settings = + DeepLinkingSettings( + deep_link_return_url: "https://platform.example.com/deep_link_return", + accept_types: ["ltiResourceLink"], + accept_presentation_document_targets: ["iframe"], + accept_media_types: option.None, + accept_multiple: option.Some(True), + auto_create: option.None, + title: option.None, + text: option.None, + data: option.None, + accept_lineitem: option.Some(False), + ) + + let items = [ + content_item.lti_resource_link( + option.Some("https://tool.example.com/launch/resource-1"), + option.Some("Resource 1"), + option.None, + option.None, + option.Some(content_item.LineItem( + score_maximum: 10.0, + resource_id: option.Some("resource-1"), + tag: option.None, + label: option.Some("Grade"), + grades_released: option.None, + )), + ), + ] + + content_item.validate_items(settings, items) + |> should.equal(Error(errors.DeepLinkingResponseInvalidItemType)) +} + +pub fn build_response_form_post_test() { + let assert Ok(html) = + deep_linking.build_response_form_post( + "https://platform.example.com/deep_link_return", + "header.payload.signature", + ) + + string.contains(html, "name=\"JWT\"") + |> should.be_true() + + string.contains(html, "header.payload.signature") + |> should.be_true() + + string.contains( + html, + "action=\"https://platform.example.com/deep_link_return\"", + ) + |> should.be_true() +} + +pub fn build_response_form_post_contract_test() { + let assert Ok(html) = + deep_linking.build_response_form_post( + "https://platform.example.com/deep_link_return", + "header.payload.signature", + ) + + string.contains( + html, + "
", + ) + |> should.be_true() + + string.contains( + html, + "", + ) + |> should.be_true() + + string.contains( + html, + "", + ) + |> should.be_true() +} + +pub fn build_response_form_post_escapes_action_and_jwt_test() { + let assert Ok(html) = + deep_linking.build_response_form_post( + "https://platform.example.com/deep_link_return?x=1&y=2", + "abc&\"ghi\"", + ) + + string.contains( + html, + "action=\"https://platform.example.com/deep_link_return?x=1&y=2\"", + ) + |> should.be_true() + + string.contains(html, "value=\"abc<def>&"ghi"\"") + |> should.be_true() +} + +pub fn build_response_form_post_invalid_url_test() { + deep_linking.build_response_form_post("not-a-url", "jwt") + |> should.equal(Error(errors.DeepLinkingResponseInvalidReturnUrl)) +} + +pub fn deep_linking_error_to_string_conversion_test() { + errors.deep_linking_error_to_string(errors.DeepLinkingClaimMissing) + |> should.equal("Missing required deep-linking claim.") +} + +fn decode_claim_string(claims, key: String) -> Result(String, String) { + dict.get(claims, key) + |> result.map_error(fn(_) { "missing" }) + |> result.try(fn(raw) { + decode.run(raw, decode.string) |> result.map_error(fn(_) { "invalid" }) + }) +} + +fn dynamic_settings_claim() -> dynamic.Dynamic { + dynamic.properties([ + #( + dynamic.string("deep_link_return_url"), + dynamic.string("https://platform.example.com/deep_link_return"), + ), + #( + dynamic.string("accept_types"), + dynamic.list([dynamic.string("ltiResourceLink"), dynamic.string("link")]), + ), + #( + dynamic.string("accept_presentation_document_targets"), + dynamic.list([dynamic.string("iframe")]), + ), + #(dynamic.string("accept_multiple"), dynamic.bool(True)), + #(dynamic.string("accept_lineitem"), dynamic.bool(True)), + #(dynamic.string("data"), dynamic.string("opaque-platform-data")), + ]) +} diff --git a/test/lightbulb/providers/memory_provider/login_context_test.gleam b/test/lightbulb/providers/memory_provider/login_context_test.gleam new file mode 100644 index 0000000..2a95b29 --- /dev/null +++ b/test/lightbulb/providers/memory_provider/login_context_test.gleam @@ -0,0 +1,32 @@ +import gleam/time/duration +import gleam/time/timestamp +import gleeunit/should +import lightbulb/providers/data_provider.{LoginContext} +import lightbulb/providers/memory_provider + +pub fn login_context_round_trip_and_consume_test() { + let assert Ok(memory) = memory_provider.start() + + let context = + LoginContext( + state: "state-123", + target_link_uri: "https://tool.example.com/launch", + issuer: "https://platform.example.com", + client_id: "client-123", + expires_at: timestamp.system_time() |> timestamp.add(duration.minutes(5)), + ) + + memory_provider.save_login_context(memory, context) + |> should.equal(Ok(Nil)) + + memory_provider.get_login_context(memory, "state-123") + |> should.equal(Ok(context)) + + memory_provider.consume_login_context(memory, "state-123") + |> should.equal(Ok(Nil)) + + memory_provider.get_login_context(memory, "state-123") + |> should.equal(Error(Nil)) + + memory_provider.cleanup(memory) +} diff --git a/test/lightbulb/services/access_token_cache_test.gleam b/test/lightbulb/services/access_token_cache_test.gleam new file mode 100644 index 0000000..f249b5c --- /dev/null +++ b/test/lightbulb/services/access_token_cache_test.gleam @@ -0,0 +1,155 @@ +import gleam/http/request.{type Request} +import gleam/http/response +import gleam/option.{None, Some} +import gleeunit/should +import lightbulb/jwk +import lightbulb/providers +import lightbulb/providers/http_mock_provider +import lightbulb/providers/memory_provider +import lightbulb/registration.{Registration} +import lightbulb/services/access_token.{AccessToken} +import lightbulb/services/access_token_cache.{CachedToken} + +pub fn cache_get_hit_and_stale_test() { + let cache = access_token_cache.with_refresh_window(60) + let cache_key = + access_token_cache.key("issuer", "client", ["scope:one", "scope:two"]) + + let cached = + CachedToken( + token: AccessToken("cached-token", "Bearer", 3600, "scope:one scope:two"), + expires_at_unix: 2000, + ) + + let cache = access_token_cache.put(cache, cache_key, cached) + + access_token_cache.get(cache, cache_key, 1900) + |> should.equal(Some(cached)) + + access_token_cache.get(cache, cache_key, 1940) + |> should.equal(None) +} + +pub fn fetch_access_token_with_cache_hit_test() { + let #(memory, registration, providers) = + setup(fn(_req: Request(String)) { + Error("HTTP should not be used for a fresh cache hit") + }) + + let scopes = ["scope:one", "scope:two"] + let cache_key = + access_token_cache.key(registration.issuer, registration.client_id, scopes) + + let cache = + access_token_cache.new() + |> access_token_cache.put( + cache_key, + CachedToken( + token: AccessToken( + "cached-token", + "Bearer", + 3600, + "scope:one scope:two", + ), + expires_at_unix: 3_000_000_000, + ), + ) + + access_token_cache.fetch_access_token_with_cache( + cache, + providers, + registration, + scopes, + ) + |> should.equal( + Ok(#( + AccessToken("cached-token", "Bearer", 3600, "scope:one scope:two"), + cache, + )), + ) + + memory_provider.cleanup(memory) +} + +pub fn fetch_access_token_with_cache_refresh_test() { + let #(memory, registration, providers) = + setup(fn(_req) { + response.new(200) + |> response.set_body( + "{\"access_token\":\"fetched-token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"scope\":\"scope:one scope:two\"}", + ) + |> Ok + }) + + let scopes = ["scope:one", "scope:two"] + let cache_key = + access_token_cache.key(registration.issuer, registration.client_id, scopes) + + let stale_cache = + access_token_cache.with_refresh_window(60) + |> access_token_cache.put( + cache_key, + CachedToken( + token: AccessToken("stale-token", "Bearer", 20, "scope:one scope:two"), + expires_at_unix: 1, + ), + ) + + let assert Ok(#(token, refreshed_cache)) = + access_token_cache.fetch_access_token_with_cache( + stale_cache, + providers, + registration, + scopes, + ) + + token + |> should.equal(AccessToken( + "fetched-token", + "Bearer", + 3600, + "scope:one scope:two", + )) + + let assert Some(CachedToken(token: cached, ..)) = + access_token_cache.get(refreshed_cache, cache_key, 2) + + cached + |> should.equal(AccessToken( + "fetched-token", + "Bearer", + 3600, + "scope:one scope:two", + )) + + memory_provider.cleanup(memory) +} + +fn setup(expect_http_post) { + let assert Ok(memory) = memory_provider.start() + let assert Ok(data_provider) = memory_provider.data_provider(memory) + + let assert Ok(active_jwk) = jwk.generate() + memory_provider.create_jwk(memory, active_jwk) + + let assert Ok(#(_, registration)) = + memory_provider.create_registration( + memory, + Registration( + name: "Example Registration", + issuer: "http://example.com", + client_id: "SOME_CLIENT_ID", + auth_endpoint: "http://example.com/lti/authorize_redirect", + access_token_endpoint: "http://example.com/auth/token", + keyset_url: "http://example.com/jwks.json", + ), + ) + + let providers = + providers.Providers( + data: data_provider, + http: http_mock_provider.http_provider(expect_http_post), + ) + + #(memory, registration, providers) +} diff --git a/test/lightbulb/services/access_token_test.gleam b/test/lightbulb/services/access_token_test.gleam index 9138583..b2a5740 100644 --- a/test/lightbulb/services/access_token_test.gleam +++ b/test/lightbulb/services/access_token_test.gleam @@ -1,28 +1,240 @@ +import gleam/dict +import gleam/dynamic/decode import gleam/http -import gleam/http/request.{type Request} +import gleam/http/request import gleam/http/response +import gleam/option.{None, Some} +import gleam/result +import gleam/string import gleeunit/should +import lightbulb/jose import lightbulb/jwk import lightbulb/providers import lightbulb/providers/http_mock_provider import lightbulb/providers/memory_provider -import lightbulb/registration.{Registration} -import lightbulb/services/access_token.{AccessToken} +import lightbulb/registration.{type Registration, Registration} +import lightbulb/services/access_token.{ + AccessToken, AssertionBuildError, AssertionOptions, HttpStatusError, + OAuthError, access_token_error_to_string, build_client_assertion, + fetch_access_token, +} import lightbulb/services/ags import lightbulb/services/nrps -pub fn active_jwk_test() { - let assert Ok(memory_provider) = memory_provider.start() - let assert Ok(lti_data_provider) = - memory_provider.data_provider(memory_provider) +type Fixture { + Fixture( + memory: memory_provider.MemoryProvider, + providers: providers.Providers, + registration: Registration, + scopes: List(String), + active_jwk: jwk.Jwk, + ) +} + +pub fn access_token_success_and_request_shape_test() { + let fixture = + setup_fixture(fn(req) { + req.path + |> should.equal("/auth/token") + + req.method + |> should.equal(http.Post) + + req + |> request.get_header("content-type") + |> should.equal(Ok("application/x-www-form-urlencoded")) + + req + |> request.get_header("accept") + |> should.equal(Ok("application/json")) + + req.body + |> string.contains("grant_type=client_credentials") + |> should.equal(True) + + req.body + |> string.contains( + "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", + ) + |> should.equal(True) + + req.body + |> string.contains("scope=") + |> should.equal(True) + + req.body + |> string.contains("client_assertion=") + |> should.equal(True) + + response.new(200) + |> response.set_body( + "\n {\n \"access_token\": \"SOME_ACCESS_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"scope\": \"some scopes\"\n }\n ", + ) + |> Ok + }) + + fetch_access_token(fixture.providers, fixture.registration, fixture.scopes) + |> should.equal( + Ok(AccessToken("SOME_ACCESS_TOKEN", "Bearer", 3600, "some scopes")), + ) + + memory_provider.cleanup(fixture.memory) +} - let assert Ok(jwk) = jwk.generate() +pub fn tolerant_decode_missing_optional_fields_test() { + let fixture = + setup_fixture(fn(_req) { + response.new(200) + |> response.set_body( + "{\"access_token\":\"SOME_ACCESS_TOKEN\",\"token_type\":\"Bearer\"}", + ) + |> Ok + }) - memory_provider.create_jwk(memory_provider, jwk) + fetch_access_token(fixture.providers, fixture.registration, fixture.scopes) + |> should.equal( + Ok(AccessToken( + token: "SOME_ACCESS_TOKEN", + token_type: "Bearer", + expires_in: 0, + scope: ags.lineitem_scope_url + <> " " + <> ags.result_readonly_scope_url + <> " " + <> ags.scores_scope_url + <> " " + <> nrps.context_membership_readonly_claim_url, + )), + ) + + memory_provider.cleanup(fixture.memory) +} + +pub fn oauth_error_mapping_test() { + let fixture = + setup_fixture(fn(_req) { + response.new(401) + |> response.set_body( + "{\"error\":\"invalid_client\",\"error_description\":\"bad secret\",\"error_uri\":\"https://example.com/errors/invalid_client\"}", + ) + |> Ok + }) + + fetch_access_token(fixture.providers, fixture.registration, fixture.scopes) + |> should.equal( + Error(OAuthError( + error: "invalid_client", + error_description: Some("bad secret"), + error_uri: Some("https://example.com/errors/invalid_client"), + )), + ) + + access_token_error_to_string(OAuthError( + error: "invalid_client", + error_description: Some("bad secret"), + error_uri: Some("https://example.com/errors/invalid_client"), + )) + |> should.equal("OAuth token endpoint error (invalid_client): bad secret") + + memory_provider.cleanup(fixture.memory) +} + +pub fn non_json_error_body_maps_to_http_status_error_test() { + let fixture = + setup_fixture(fn(_req) { + response.new(500) + |> response.set_body("gateway unavailable") + |> Ok + }) + + fetch_access_token(fixture.providers, fixture.registration, fixture.scopes) + |> should.equal( + Error(HttpStatusError(status: 500, body: "gateway unavailable")), + ) + + memory_provider.cleanup(fixture.memory) +} + +pub fn assertion_claims_include_required_fields_and_hardened_lifetime_test() { + let fixture = setup_fixture(fn(_req) { Ok(response.new(200)) }) + + let options = + AssertionOptions( + audience: Some("https://platform.example.com/oauth2"), + lifetime_seconds: 120, + ) + + let assert Ok(jwt) = + build_client_assertion( + fixture.active_jwk, + "https://platform.example.com/oauth2", + fixture.registration.client_id, + options, + ) + + let jose.JoseJwt(claims: claims) = jose.peek(jwt) + let jose.JoseJws(headers: headers, ..) = jose.peek_protected(jwt) + + decode_claim_string(claims, "iss") + |> should.equal(Ok(fixture.registration.client_id)) + + decode_claim_string(claims, "sub") + |> should.equal(Ok(fixture.registration.client_id)) + + decode_claim_string(claims, "aud") + |> should.equal(Ok("https://platform.example.com/oauth2")) + + decode_claim_string(headers, "kid") + |> should.equal(Ok(fixture.active_jwk.kid)) + + let assert Ok(iat) = decode_claim_int(claims, "iat") + let assert Ok(exp) = decode_claim_int(claims, "exp") + + let lifetime_valid = exp - iat > 0 && exp - iat <= 121 + lifetime_valid + |> should.equal(True) + + decode_claim_string(claims, "jti") + |> result.map(fn(value) { string.length(value) > 0 }) + |> should.equal(Ok(True)) + + memory_provider.cleanup(fixture.memory) +} + +pub fn assertion_build_validation_errors_test() { + let fixture = setup_fixture(fn(_req) { Ok(response.new(200)) }) + + build_client_assertion( + jwk.Jwk(..fixture.active_jwk, kid: ""), + fixture.registration.access_token_endpoint, + fixture.registration.client_id, + AssertionOptions(audience: None, lifetime_seconds: 120), + ) + |> should.equal(Error(AssertionBuildError("active JWK is missing kid"))) + + build_client_assertion( + fixture.active_jwk, + fixture.registration.access_token_endpoint, + fixture.registration.client_id, + AssertionOptions(audience: None, lifetime_seconds: 0), + ) + |> should.equal( + Error(AssertionBuildError("assertion lifetime must be greater than zero")), + ) + + memory_provider.cleanup(fixture.memory) +} + +fn setup_fixture(expect_http_post) -> Fixture { + let assert Ok(memory) = memory_provider.start() + let assert Ok(data_provider) = memory_provider.data_provider(memory) + + let assert Ok(active_jwk) = jwk.generate() + memory_provider.create_jwk(memory, active_jwk) let assert Ok(#(_, registration)) = memory_provider.create_registration( - memory_provider, + memory, Registration( name: "Example Registration", issuer: "http://example.com", @@ -40,35 +252,37 @@ pub fn active_jwk_test() { nrps.context_membership_readonly_claim_url, ] - let expect_http_post = fn(req: Request(String)) { - req.path - |> should.equal("/auth/token") - - req.method - |> should.equal(http.Post) - - response.new(200) - |> response.set_body( - " - { - \"access_token\": \"SOME_ACCESS_TOKEN\", - \"token_type\": \"Bearer\", - \"expires_in\": 3600, - \"scope\": \"some scopes\" - } - ", - ) - |> Ok - } - let providers = providers.Providers( - data: lti_data_provider, + data: data_provider, http: http_mock_provider.http_provider(expect_http_post), ) - access_token.fetch_access_token(providers, registration, scopes) - |> should.equal( - Ok(AccessToken("SOME_ACCESS_TOKEN", "Bearer", 3600, "some scopes")), + Fixture( + memory: memory, + providers: providers, + registration: registration, + scopes: scopes, + active_jwk: active_jwk, ) } + +fn decode_claim_string(claims, key: String) { + claims + |> dict.get(key) + |> result.replace_error(Nil) + |> result.try(fn(value) { + decode.run(value, decode.string) + |> result.replace_error(Nil) + }) +} + +fn decode_claim_int(claims, key: String) { + claims + |> dict.get(key) + |> result.replace_error(Nil) + |> result.try(fn(value) { + decode.run(value, decode.int) + |> result.replace_error(Nil) + }) +} diff --git a/test/lightbulb/services/ags_link_header_test.gleam b/test/lightbulb/services/ags_link_header_test.gleam new file mode 100644 index 0000000..07d4c9b --- /dev/null +++ b/test/lightbulb/services/ags_link_header_test.gleam @@ -0,0 +1,38 @@ +import gleam/option.{None, Some} +import gleeunit/should +import lightbulb/http/link_header + +pub fn parse_link_header_test() { + link_header.parse( + "; rel=\"next\", ; rel=\"first\", ; rel=\"last\", ; rel=\"prev\"", + ) + |> should.equal( + Ok(link_header.PageLinks( + next: Some("https://example.com/items?page=2"), + differences: None, + prev: Some("https://example.com/items?page=1"), + first: Some("https://example.com/items?page=1"), + last: Some("https://example.com/items?page=4"), + )), + ) +} + +pub fn malformed_link_header_test() { + let result = link_header.parse("broken-link") + + case result { + Error(link_header.InvalidLinkHeader(_)) -> True |> should.equal(True) + _ -> False |> should.equal(True) + } +} + +pub fn empty_links_default_test() { + link_header.empty_page_links() + |> should.equal(link_header.PageLinks( + next: None, + differences: None, + prev: None, + first: None, + last: None, + )) +} diff --git a/test/lightbulb/services/ags_test.gleam b/test/lightbulb/services/ags_test.gleam index 06b7287..77eee9c 100644 --- a/test/lightbulb/services/ags_test.gleam +++ b/test/lightbulb/services/ags_test.gleam @@ -1,62 +1,542 @@ +import gleam/dict +import gleam/dynamic import gleam/http -import gleam/http/request.{type Request} +import gleam/http/request import gleam/http/response -import gleam/option.{Some} +import gleam/list +import gleam/option +import gleam/string import gleeunit/should +import lightbulb/http/link_header.{PageLinks} import lightbulb/providers/http_mock_provider -import lightbulb/services/access_token.{AccessToken} +import lightbulb/services/access_token.{type AccessToken, AccessToken} import lightbulb/services/ags -import lightbulb/services/ags/line_item.{LineItem} -import lightbulb/services/ags/score.{Score} +import lightbulb/services/ags/line_item.{type LineItem, LineItem} +import lightbulb/services/ags/result as ags_result +import lightbulb/services/ags/score.{type Score, Score} pub fn post_score_test() { - let score = - Score( - score_given: 1.0, - score_maximum: 2.0, - timestamp: "2023-10-01T00:00:00Z", - user_id: "user123", - comment: "Great job!", - activity_progress: "Completed", - grading_progress: "FullyGraded", + let score = fixture_score() + let line_item = + fixture_line_item("https://lms.example.com/context/2923/lineitems/1") + + let expect_http_post = fn(req: request.Request(String)) { + req.path + |> should.equal("/context/2923/lineitems/1/scores") + + req.method + |> should.equal(http.Post) + + req + |> request.get_header("content-type") + |> should.equal(Ok("application/vnd.ims.lis.v1.score+json")) + + req + |> request.get_header("accept") + |> should.equal(Ok("application/json")) + + response.new(202) + |> response.set_body("{}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_post) + + ags.post_score(http_provider, score, line_item, fixture_access_token()) + |> should.be_ok() +} + +pub fn create_line_item_test() { + let expect_http_post = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Post) + + req.path + |> should.equal("/context/2923/lineitems") + + req + |> request.get_header("accept") + |> should.equal(Ok("application/vnd.ims.lis.v2.lineitem+json")) + + req + |> request.get_header("content-type") + |> should.equal(Ok("application/vnd.ims.lis.v2.lineitem+json")) + + req.body + |> string.contains("\"resourceId\":\"resource123\"") + |> should.equal(True) + + response.new(201) + |> response.set_body( + "{\"id\":\"https://lms.example.com/context/2923/lineitems/5\",\"scoreMaximum\":10.0,\"label\":\"Unit 1\",\"resourceId\":\"resource123\",\"resourceLinkId\":\"rl-1\",\"tag\":\"unit\",\"gradesReleased\":true}", ) + |> Ok + } - let line_item = - LineItem( - id: Some("https://lms.example.com/context/2923/lineitems/1"), - score_maximum: 2.0, - label: "Test Line Item", + let http_provider = http_mock_provider.http_provider(expect_http_post) + + ags.create_line_item( + http_provider, + "https://lms.example.com/context/2923/lineitems", + "resource123", + 10.0, + "Unit 1", + fixture_access_token(), + ) + |> should.equal( + Ok(LineItem( + id: option.Some("https://lms.example.com/context/2923/lineitems/5"), + score_maximum: 10.0, + label: "Unit 1", resource_id: "resource123", + resource_link_id: option.Some("rl-1"), + tag: option.Some("unit"), + start_date_time: option.None, + end_date_time: option.None, + grades_released: option.Some(True), + )), + ) +} + +pub fn fetch_or_create_line_item_existing_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Get) + + req.path + |> should.equal("/context/2923/lineitems") + + req.query + |> option.unwrap("") + |> string.contains("resource_id=resource123") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("limit=1") + |> should.equal(True) + + response.new(200) + |> response.set_body( + "[{\"id\":\"https://lms.example.com/context/2923/lineitems/8\",\"scoreMaximum\":10.0,\"label\":\"Unit 1\",\"resourceId\":\"resource123\"}]", ) + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + ags.fetch_or_create_line_item( + http_provider, + "https://lms.example.com/context/2923/lineitems", + "resource123", + fn() { 10.0 }, + "Unit 1", + fixture_access_token(), + ) + |> should.equal( + Ok(LineItem( + id: option.Some("https://lms.example.com/context/2923/lineitems/8"), + score_maximum: 10.0, + label: "Unit 1", + resource_id: "resource123", + resource_link_id: option.None, + tag: option.None, + start_date_time: option.None, + end_date_time: option.None, + grades_released: option.None, + )), + ) +} + +pub fn get_line_item_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Get) - let access_token = - AccessToken( - token: "SOME_ACCESS_TOKEN", - token_type: "Bearer", - expires_in: 3600, - scope: "some scopes", + req.path + |> should.equal("/context/2923/lineitems/1") + + req + |> request.get_header("accept") + |> should.equal(Ok("application/vnd.ims.lis.v2.lineitem+json")) + + response.new(200) + |> response.set_body( + "{\"id\":\"https://lms.example.com/context/2923/lineitems/1\",\"scoreMaximum\":2.0,\"label\":\"Test Line Item\",\"resourceId\":\"resource123\"}", ) + |> Ok + } - let expect_http_post = fn(req: Request(String)) { - req.scheme - |> should.equal(http.Https) + let http_provider = http_mock_provider.http_provider(expect_http_get) - req.host - |> should.equal("lms.example.com") + ags.get_line_item( + http_provider, + "https://lms.example.com/context/2923/lineitems/1", + fixture_access_token(), + ) + |> should.be_ok() +} + +pub fn list_line_items_paging_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Get) req.path - |> should.equal("/context/2923/lineitems/1/scores") + |> should.equal("/context/2923/lineitems") + + req.query + |> option.unwrap("") + |> string.contains("resource_id=resource123") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("tag=quiz") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("limit=5") + |> should.equal(True) + + req + |> request.get_header("accept") + |> should.equal(Ok("application/vnd.ims.lis.v2.lineitemcontainer+json")) + + response.new(200) + |> response.set_header( + "link", + "; rel=\"next\", ; rel=\"first\"", + ) + |> response.set_body( + "[{\"id\":\"https://lms.example.com/context/2923/lineitems/8\",\"scoreMaximum\":10.0,\"label\":\"Unit 1\",\"resourceId\":\"resource123\"}]", + ) + |> Ok + } + + let query = + ags.LineItemsQuery( + resource_link_id: option.None, + resource_id: option.Some("resource123"), + tag: option.Some("quiz"), + limit: option.Some(5), + ) + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + let assert Ok(ags.Paged(items: items, links: links)) = + ags.list_line_items( + http_provider, + "https://lms.example.com/context/2923/lineitems", + query, + fixture_access_token(), + ) + + list.length(items) + |> should.equal(1) + links + |> should.equal(PageLinks( + next: option.Some("https://lms.example.com/context/2923/lineitems?page=2"), + differences: option.None, + prev: option.None, + first: option.Some("https://lms.example.com/context/2923/lineitems?page=1"), + last: option.None, + )) +} + +pub fn update_line_item_test() { + let expect_http_put = fn(req: request.Request(String)) { req.method - |> should.equal(http.Post) + |> should.equal(http.Put) + + req.path + |> should.equal("/context/2923/lineitems/1") + + req + |> request.get_header("accept") + |> should.equal(Ok("application/vnd.ims.lis.v2.lineitem+json")) response.new(200) - |> response.set_body("{}") + |> response.set_body( + "{\"id\":\"https://lms.example.com/context/2923/lineitems/1\",\"scoreMaximum\":3.0,\"label\":\"Updated\",\"resourceId\":\"resource123\"}", + ) |> Ok } - let http_provider = http_mock_provider.http_provider(expect_http_post) + let http_provider = http_mock_provider.http_provider(expect_http_put) - ags.post_score(http_provider, score, line_item, access_token) + ags.update_line_item( + http_provider, + LineItem( + id: option.Some("https://lms.example.com/context/2923/lineitems/1"), + score_maximum: 3.0, + label: "Updated", + resource_id: "resource123", + resource_link_id: option.None, + tag: option.None, + start_date_time: option.None, + end_date_time: option.None, + grades_released: option.None, + ), + fixture_access_token(), + ) |> should.be_ok() } + +pub fn delete_line_item_test() { + let expect_http_delete = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Delete) + + req.path + |> should.equal("/context/2923/lineitems/1") + + response.new(204) + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_delete) + + ags.delete_line_item( + http_provider, + "https://lms.example.com/context/2923/lineitems/1", + fixture_access_token(), + ) + |> should.equal(Ok(Nil)) +} + +pub fn list_results_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Get) + + req.path + |> should.equal("/context/2923/lineitems/1/results") + + req.query + |> option.unwrap("") + |> string.contains("resource_link_id=abc") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("user_id=user123") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("limit=10") + |> should.equal(True) + + req + |> request.get_header("accept") + |> should.equal(Ok("application/vnd.ims.lis.v2.resultcontainer+json")) + + response.new(200) + |> response.set_body( + "[{\"id\":\"r1\",\"userId\":\"user123\",\"resultScore\":1.0,\"resultMaximum\":2.0,\"comment\":\"Great\",\"scoreOf\":\"https://lms.example.com/context/2923/lineitems/1\"}]", + ) + |> Ok + } + + let query = + ags.ResultsQuery(user_id: option.Some("user123"), limit: option.Some(10)) + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + ags.list_results( + http_provider, + "https://lms.example.com/context/2923/lineitems/1?resource_link_id=abc", + query, + fixture_access_token(), + ) + |> should.equal( + Ok(ags.Paged( + items: [ + ags_result.Result( + id: option.Some("r1"), + user_id: "user123", + result_score: option.Some(1.0), + result_maximum: option.Some(2.0), + comment: option.Some("Great"), + score_of: option.Some( + "https://lms.example.com/context/2923/lineitems/1", + ), + ), + ], + links: PageLinks( + next: option.None, + differences: option.None, + prev: option.None, + first: option.None, + last: option.None, + ), + )), + ) +} + +pub fn invalid_line_item_payload_test() { + let expect_http_get = fn(_req: request.Request(String)) { + response.new(200) + |> response.set_body("{\"invalid\":true}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + let result = + ags.get_line_item( + http_provider, + "https://lms.example.com/context/2923/lineitems/1", + fixture_access_token(), + ) + + case result { + Error(ags.DecodeLineItem(_)) -> True |> should.equal(True) + _ -> False |> should.equal(True) + } +} + +pub fn invalid_result_payload_test() { + let expect_http_get = fn(_req: request.Request(String)) { + response.new(200) + |> response.set_body("[{\"invalid\":true}]") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + let result = + ags.list_results( + http_provider, + "https://lms.example.com/context/2923/lineitems/1", + ags.default_results_query(), + fixture_access_token(), + ) + + case result { + Error(ags.DecodeResult(_)) -> True |> should.equal(True) + _ -> False |> should.equal(True) + } +} + +pub fn missing_scope_guard_test() { + let claims = ags_claim([ags.result_readonly_scope_url]) + + ags.require_can_post_scores(claims) + |> should.equal(Error(ags.ScopeInsufficient(ags.scores_scope_url))) +} + +pub fn scope_helpers_test() { + let claims = + ags_claim([ + ags.lineitem_readonly_scope_url, + ags.result_readonly_scope_url, + ags.scores_scope_url, + ]) + + ags.can_read_line_items(claims) + |> should.equal(True) + + ags.can_post_scores(claims) + |> should.equal(True) + + ags.can_read_results(claims) + |> should.equal(True) + + ags.can_write_line_items(claims) + |> should.equal(False) + + ags.grade_passback_available(claims) + |> should.equal(True) +} + +pub fn malformed_link_header_fallback_test() { + let expect_http_get = fn(_req: request.Request(String)) { + response.new(200) + |> response.set_header("link", "broken-link-header") + |> response.set_body("[]") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + ags.list_line_items( + http_provider, + "https://lms.example.com/context/2923/lineitems", + ags.default_line_items_query(), + fixture_access_token(), + ) + |> should.equal( + Ok(ags.Paged( + items: [], + links: PageLinks( + next: option.None, + differences: option.None, + prev: option.None, + first: option.None, + last: option.None, + ), + )), + ) +} + +fn fixture_access_token() -> AccessToken { + AccessToken( + token: "SOME_ACCESS_TOKEN", + token_type: "Bearer", + expires_in: 3600, + scope: "some scopes", + ) +} + +fn fixture_score() -> Score { + Score( + score_given: 1.0, + score_maximum: 2.0, + timestamp: "2023-10-01T00:00:00Z", + user_id: "user123", + comment: "Great job!", + activity_progress: "Completed", + grading_progress: "FullyGraded", + ) +} + +fn fixture_line_item(line_item_id: String) -> LineItem { + LineItem( + id: option.Some(line_item_id), + score_maximum: 2.0, + label: "Test Line Item", + resource_id: "resource123", + resource_link_id: option.None, + tag: option.None, + start_date_time: option.None, + end_date_time: option.None, + grades_released: option.None, + ) +} + +fn ags_claim(scopes: List(String)) -> dict.Dict(String, dynamic.Dynamic) { + dict.from_list([ + #( + ags.lti_ags_claim_url, + dynamic.properties([ + #( + dynamic.string("lineitem"), + dynamic.string("https://lms.example.com/context/2923/lineitems/1"), + ), + #( + dynamic.string("lineitems"), + dynamic.string("https://lms.example.com/context/2923/lineitems"), + ), + #( + dynamic.string("scope"), + dynamic.list(list.map(scopes, dynamic.string)), + ), + #(dynamic.string("errors"), dynamic.properties([])), + ]), + ), + ]) +} diff --git a/test/lightbulb/services/nrps_paging_test.gleam b/test/lightbulb/services/nrps_paging_test.gleam new file mode 100644 index 0000000..c75663f --- /dev/null +++ b/test/lightbulb/services/nrps_paging_test.gleam @@ -0,0 +1,165 @@ +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/option +import gleeunit/should +import lightbulb/http/link_header.{PageLinks} +import lightbulb/providers/http_mock_provider +import lightbulb/services/access_token.{type AccessToken, AccessToken} +import lightbulb/services/nrps +import lightbulb/services/nrps/membership.{Membership} + +pub fn next_link_continuation_flow_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.path + |> should.equal("/context/2923/memberships") + + req.query + |> should.equal(option.Some("page=2")) + + response.new(200) + |> response.set_header( + "link", + "; rel=\"next\", ; rel=\"differences\"", + ) + |> response.set_body( + "{\"members\":[{\"user_id\":\"user-2\",\"roles\":[\"Learner\"]}]}", + ) + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + nrps.fetch_next_memberships_page( + http_provider, + "https://lms.example.com/context/2923/memberships?page=2", + fixture_access_token(), + ) + |> should.equal( + Ok(nrps.MembershipsPage( + members: [ + Membership( + user_id: "user-2", + roles: ["Learner"], + status: option.None, + name: option.None, + given_name: option.None, + family_name: option.None, + middle_name: option.None, + email: option.None, + picture: option.None, + lis_person_sourcedid: option.None, + ), + ], + links: PageLinks( + next: option.Some( + "https://lms.example.com/context/2923/memberships?page=3", + ), + differences: option.Some( + "https://lms.example.com/context/2923/memberships?since=100", + ), + prev: option.None, + first: option.None, + last: option.None, + ), + )), + ) +} + +pub fn differences_link_continuation_flow_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.path + |> should.equal("/context/2923/memberships") + + req.query + |> should.equal(option.Some("since=100")) + + response.new(200) + |> response.set_body("{\"members\":[]}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + nrps.fetch_differences_memberships_page( + http_provider, + "https://lms.example.com/context/2923/memberships?since=100", + fixture_access_token(), + ) + |> should.equal( + Ok(nrps.MembershipsPage( + members: [], + links: PageLinks( + next: option.None, + differences: option.None, + prev: option.None, + first: option.None, + last: option.None, + ), + )), + ) +} + +pub fn malformed_link_header_fallback_behavior_test() { + let expect_http_get = fn(_req: request.Request(String)) { + response.new(200) + |> response.set_header("link", "broken-link") + |> response.set_body("{\"members\":[]}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + nrps.fetch_memberships_with_options( + http_provider, + "https://lms.example.com/context/2923/memberships", + nrps.default_memberships_query(), + fixture_access_token(), + ) + |> should.equal( + Ok(nrps.MembershipsPage( + members: [], + links: PageLinks( + next: option.None, + differences: option.None, + prev: option.None, + first: option.None, + last: option.None, + ), + )), + ) +} + +pub fn access_token_header_for_nrps_calls_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.method + |> should.equal(http.Get) + + req + |> request.get_header("authorization") + |> should.equal(Ok("Bearer SOME_ACCESS_TOKEN")) + + response.new(200) + |> response.set_body("{\"members\":[]}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + nrps.fetch_memberships_with_options( + http_provider, + "https://lms.example.com/context/2923/memberships", + nrps.default_memberships_query(), + fixture_access_token(), + ) + |> should.be_ok() +} + +fn fixture_access_token() -> AccessToken { + AccessToken( + token: "SOME_ACCESS_TOKEN", + token_type: "Bearer", + expires_in: 3600, + scope: "some scopes", + ) +} diff --git a/test/lightbulb/services/nrps_test.gleam b/test/lightbulb/services/nrps_test.gleam index 108c850..39329b6 100644 --- a/test/lightbulb/services/nrps_test.gleam +++ b/test/lightbulb/services/nrps_test.gleam @@ -1,50 +1,46 @@ +import gleam/dict +import gleam/dynamic import gleam/http -import gleam/http/request.{type Request} +import gleam/http/request import gleam/http/response -import gleam/list +import gleam/json +import gleam/option +import gleam/string import gleeunit/should import lightbulb/providers/http_mock_provider -import lightbulb/services/access_token.{AccessToken} +import lightbulb/services/access_token.{type AccessToken, AccessToken} import lightbulb/services/nrps -import lightbulb/services/nrps/membership.{Membership} +import lightbulb/services/nrps/membership pub fn fetch_memberships_test() { - let expect_http_post = fn(req: Request(String)) { - req.scheme - |> should.equal(http.Https) - - req.host - |> should.equal("lms.example.com") - + let expect_http_get = fn(req: request.Request(String)) { req.path |> should.equal("/lti/courses/350/names_and_roles") req.method |> should.equal(http.Get) + req.query + |> should.equal(option.None) + + req + |> request.get_header("accept") + |> should.equal(Ok( + "application/vnd.ims.lti-nrps.v2.membershipcontainer+json", + )) + response.new(200) |> response.set_body( "{ \"members\": [ { \"user_id\": \"12345\", - \"status\": \"active\", - \"name\": \"John Doe\", - \"given_name\": \"John\", - \"family_name\": \"Doe\", - \"email\": \"john.doe@example.edu\", - \"roles\": [\"Instructor\"], - \"picture\": \"https://example.edu/john_doe.jpg\" + \"roles\": [\"Instructor\"] }, { \"user_id\": \"67890\", - \"status\": \"active\", - \"name\": \"Jane Smith\", - \"given_name\": \"Jane\", - \"family_name\": \"Smith\", - \"email\": \"jane.smith@example.edu\", - \"picture\": \"https://example.edu/jane_smith.jpg\", - \"roles\": [\"Learner\"] + \"roles\": [\"Learner\"], + \"name\": \"Jane Smith\" } ] }", @@ -52,54 +48,243 @@ pub fn fetch_memberships_test() { |> Ok } - let http_provider = http_mock_provider.http_provider(expect_http_post) - - let context_memberships_url = - "https://lms.example.com/lti/courses/350/names_and_roles" - - let access_token = - AccessToken( - token: "SOME_ACCESS_TOKEN", - token_type: "Bearer", - expires_in: 3600, - scope: "some scopes", - ) + let http_provider = http_mock_provider.http_provider(expect_http_get) let result = - nrps.fetch_memberships(http_provider, context_memberships_url, access_token) + nrps.fetch_memberships( + http_provider, + "https://lms.example.com/lti/courses/350/names_and_roles", + fixture_access_token(), + ) result + |> should.equal( + Ok([ + membership.Membership( + user_id: "12345", + roles: ["Instructor"], + status: option.None, + name: option.None, + given_name: option.None, + family_name: option.None, + middle_name: option.None, + email: option.None, + picture: option.None, + lis_person_sourcedid: option.None, + ), + membership.Membership( + user_id: "67890", + roles: ["Learner"], + status: option.None, + name: option.Some("Jane Smith"), + given_name: option.None, + family_name: option.None, + middle_name: option.None, + email: option.None, + picture: option.None, + lis_person_sourcedid: option.None, + ), + ]), + ) +} + +pub fn options_query_serialization_test() { + let expect_http_get = fn(req: request.Request(String)) { + req.query + |> option.unwrap("") + |> string.contains("role=Instructor") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("limit=10") + |> should.equal(True) + + req.query + |> option.unwrap("") + |> string.contains("rlid=resource-link-123") + |> should.equal(True) + + response.new(200) + |> response.set_body("{\"members\":[]}") + |> Ok + } + + let http_provider = http_mock_provider.http_provider(expect_http_get) + + nrps.fetch_memberships_with_options( + http_provider, + "https://lms.example.com/lti/courses/350/names_and_roles", + nrps.MembershipsQuery( + role: option.Some("Instructor"), + limit: option.Some(10), + rlid: option.Some("resource-link-123"), + url: option.None, + ), + fixture_access_token(), + ) |> should.be_ok() +} - let assert Ok(memberships) = result +pub fn get_nrps_claim_minimal_valid_test() { + let claims = + dict.from_list([ + #( + nrps.nrps_claim_url, + dynamic.properties([ + #( + dynamic.string("context_memberships_url"), + dynamic.string("https://lms.example.com/context/100/memberships"), + ), + #( + dynamic.string("service_versions"), + dynamic.list([dynamic.string("2.0")]), + ), + ]), + ), + ]) - memberships - |> list.find(fn(m) { m.user_id == "12345" }) + nrps.get_nrps_claim(claims) |> should.equal( - Ok(Membership( - user_id: "12345", - status: "active", - name: "John Doe", - given_name: "John", - family_name: "Doe", - email: "john.doe@example.edu", - roles: ["Instructor"], - picture: "https://example.edu/john_doe.jpg", - )), + Ok( + nrps.NrpsClaim( + context_memberships_url: "https://lms.example.com/context/100/memberships", + service_versions: ["2.0"], + ), + ), + ) +} + +pub fn get_nrps_claim_invalid_missing_service_versions_test() { + let claims = + dict.from_list([ + #( + nrps.nrps_claim_url, + dynamic.properties([ + #( + dynamic.string("context_memberships_url"), + dynamic.string("https://lms.example.com/context/100/memberships"), + ), + ]), + ), + ]) + + nrps.get_nrps_claim(claims) + |> should.equal(Error(nrps.ClaimInvalid)) +} + +pub fn scope_availability_helpers_test() { + let claims = + dict.from_list([ + #( + nrps.nrps_claim_url, + dynamic.properties([ + #( + dynamic.string("context_memberships_url"), + dynamic.string("https://lms.example.com/context/100/memberships"), + ), + #( + dynamic.string("service_versions"), + dynamic.list([dynamic.string("2.0")]), + ), + #( + dynamic.string("scope"), + dynamic.list([ + dynamic.string(nrps.context_membership_readonly_claim_url), + ]), + ), + ]), + ), + ]) + + nrps.can_read_memberships(claims) + |> should.equal(True) + + nrps.nrps_available(claims) + |> should.equal(True) +} + +pub fn require_can_read_memberships_failure_test() { + let claims = + dict.from_list([ + #( + nrps.nrps_claim_url, + dynamic.properties([ + #( + dynamic.string("context_memberships_url"), + dynamic.string("https://lms.example.com/context/100/memberships"), + ), + #( + dynamic.string("service_versions"), + dynamic.list([dynamic.string("2.0")]), + ), + #(dynamic.string("scope"), dynamic.list([])), + ]), + ), + ]) + + nrps.require_can_read_memberships(claims) + |> should.equal( + Error(nrps.ScopeInsufficient(nrps.context_membership_readonly_claim_url)), ) +} - memberships - |> list.find(fn(m) { m.user_id == "67890" }) +pub fn minimal_member_decode_test() { + json.parse( + "{\"user_id\":\"u-1\",\"roles\":[\"Learner\"]}", + membership.decoder(), + ) |> should.equal( - Ok(Membership( - user_id: "67890", - status: "active", - name: "Jane Smith", - given_name: "Jane", - family_name: "Smith", - email: "jane.smith@example.edu", + Ok(membership.Membership( + user_id: "u-1", roles: ["Learner"], - picture: "https://example.edu/jane_smith.jpg", + status: option.None, + name: option.None, + given_name: option.None, + family_name: option.None, + middle_name: option.None, + email: option.None, + picture: option.None, + lis_person_sourcedid: option.None, + )), + ) +} + +pub fn expanded_member_decode_test() { + json.parse( + "{\"user_id\":\"u-2\",\"roles\":[\"Instructor\"],\"status\":\"active\",\"name\":\"Alex Smith\",\"given_name\":\"Alex\",\"family_name\":\"Smith\",\"middle_name\":\"Q\",\"email\":\"alex@example.edu\",\"picture\":\"https://example.edu/alex.png\",\"lis_person_sourcedid\":\"sis-123\"}", + membership.decoder(), + ) + |> should.equal( + Ok(membership.Membership( + user_id: "u-2", + roles: ["Instructor"], + status: option.Some("active"), + name: option.Some("Alex Smith"), + given_name: option.Some("Alex"), + family_name: option.Some("Smith"), + middle_name: option.Some("Q"), + email: option.Some("alex@example.edu"), + picture: option.Some("https://example.edu/alex.png"), + lis_person_sourcedid: option.Some("sis-123"), )), ) } + +pub fn missing_required_keys_failure_test() { + let result = json.parse("{\"roles\":[\"Learner\"]}", membership.decoder()) + + case result { + Error(_) -> True |> should.equal(True) + Ok(_) -> False |> should.equal(True) + } +} + +fn fixture_access_token() -> AccessToken { + AccessToken( + token: "SOME_ACCESS_TOKEN", + token_type: "Bearer", + expires_in: 3600, + scope: "some scopes", + ) +} diff --git a/test/lightbulb/tool_test.gleam b/test/lightbulb/tool_test.gleam new file mode 100644 index 0000000..433cf2d --- /dev/null +++ b/test/lightbulb/tool_test.gleam @@ -0,0 +1,51 @@ +import gleam/dict +import gleam/dynamic +import gleeunit/should +import lightbulb/deep_linking +import lightbulb/deep_linking/settings +import lightbulb/errors +import lightbulb/tool + +pub fn validate_message_type_deep_linking_request_test() { + let claims = + dict.from_list([ + #( + deep_linking.claim_message_type, + dynamic.string(deep_linking.lti_message_type_deep_linking_request), + ), + #(settings.claim_deep_linking_settings, deep_link_settings_claim()), + ]) + + tool.validate_message_type(claims) + |> should.equal(Ok(claims)) +} + +pub fn validate_message_type_deep_linking_request_missing_settings_test() { + let claims = + dict.from_list([ + #( + deep_linking.claim_message_type, + dynamic.string(deep_linking.lti_message_type_deep_linking_request), + ), + ]) + + tool.validate_message_type(claims) + |> should.equal(Error(errors.JwtInvalidClaim)) +} + +fn deep_link_settings_claim() -> dynamic.Dynamic { + dynamic.properties([ + #( + dynamic.string("deep_link_return_url"), + dynamic.string("https://platform.example.com/deep_link_return"), + ), + #( + dynamic.string("accept_types"), + dynamic.list([dynamic.string("ltiResourceLink")]), + ), + #( + dynamic.string("accept_presentation_document_targets"), + dynamic.list([dynamic.string("iframe")]), + ), + ]) +} diff --git a/test/lightbulb_test.gleam b/test/lightbulb_test.gleam index 902c4da..27b36dc 100644 --- a/test/lightbulb_test.gleam +++ b/test/lightbulb_test.gleam @@ -1,5 +1,7 @@ import gleeunit +import lightbulb/utils/logger pub fn main() -> Nil { + logger.configure_quiet() gleeunit.main() }