diff --git a/.codex/skills/feature-architect/SKILL.md b/.codex/skills/feature-architect/SKILL.md new file mode 100644 index 0000000..477fa2d --- /dev/null +++ b/.codex/skills/feature-architect/SKILL.md @@ -0,0 +1,65 @@ +--- +name: feature-architect +description: "Produce complete feature architecture deliverables from informal requests. Use when Codex must convert a rough feature idea into implementation-ready planning documents: product requirements (`prd.md`), functional design (`fdd.md`), and a phased execution plan (`plan.md`) with checkbox task tracking. Prioritize Elixir, Erlang, OTP/BEAM, and web architecture best practices." +--- + +# Feature Architect + +Create a cohesive architecture package that an engineer can implement without re-interpreting intent. + +## Workflow + +1. Capture feature intent +- Parse the informal request into: problem, users, desired outcomes, constraints, and unknowns. +- State assumptions explicitly when inputs are missing. +- Ask concise follow-up questions only when unknowns materially change design or scope. + +2. Define scope and boundaries +- Separate in-scope work from out-of-scope work. +- Define release slices (MVP vs later phases). +- Identify dependencies, risks, migration needs, and rollout constraints. + +3. Design for Elixir/OTP web systems +- Use [references/elixir-architecture.md](references/elixir-architecture.md) to choose runtime structure, process boundaries, supervision strategy, persistence model, and observability. +- Prefer fault-tolerant process design, clear module boundaries, and explicit contracts between contexts. + +4. Produce required outputs +- Create a feature folder at `docs/features//`. +- Generate exactly three files in that folder: `prd.md`, `fdd.md`, and `plan.md`. +- Use [references/document-templates.md](references/document-templates.md) as the default structure. +- Keep all three documents internally consistent for naming, scope, and acceptance criteria. + +5. Validate package quality +- Verify every functional requirement maps to design elements and implementation tasks. +- Ensure non-functional requirements (performance, reliability, security, operability) have concrete implementation considerations. +- Ensure plan tasks are actionable, testable, and sequenced. +- Ensure phases are cohesive units of functionality that are intended to be implemented in order. +- Ensure the final phase is manual QA acceptance testing. +- For large features, define PR groups that bundle one or more sequential phases for incremental delivery. + +## Output Requirements + +Always produce: + +1. `docs/features//prd.md` +- Define user problem, business goals, target users, use cases, functional requirements, non-functional requirements, success metrics, and acceptance criteria. + +2. `docs/features//fdd.md` +- Define architecture, component/module responsibilities, data model changes, interfaces/contracts, runtime/process behavior, error handling, observability, security posture, and test strategy. + +3. `docs/features//plan.md` +- Define phased implementation with checkbox tasks. +- Use unchecked markdown checkboxes for pending work: `- [ ] Task`. +- Group tasks by phase and include explicit deliverables and verification steps. +- Order phases sequentially and mark each phase as a cohesive unit of functionality. +- Include a final manual QA acceptance testing phase. +- If needed, include a PR grouping section mapping phases to PR groups for oversized features. +- Keep tasks granular enough that progress can be tracked during implementation. + +## Standards + +- Design for maintainability, fault tolerance, and operational clarity. +- Prefer explicit tradeoff notes when multiple approaches are viable. +- Avoid vague tasks such as "implement feature"; break into concrete outcomes. +- Tie each phase to measurable completion criteria. +- Keep the architecture realistic for the current codebase and team maturity. diff --git a/.codex/skills/feature-architect/agents/openai.yaml b/.codex/skills/feature-architect/agents/openai.yaml new file mode 100644 index 0000000..7bbbf00 --- /dev/null +++ b/.codex/skills/feature-architect/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Feature Architect" + short_description: "Draft PRD, FDD, and phased plan docs for Elixir apps." + default_prompt: "Turn this feature idea into prd.md, fdd.md, and plan.md with phased checkbox tasks for implementation." diff --git a/.codex/skills/feature-architect/references/document-templates.md b/.codex/skills/feature-architect/references/document-templates.md new file mode 100644 index 0000000..2eab0de --- /dev/null +++ b/.codex/skills/feature-architect/references/document-templates.md @@ -0,0 +1,179 @@ +# Feature Architect Templates + +Use these templates as defaults unless the user specifies a different format. + +## `prd.md` + +```markdown +# Product Requirements Document + +## 1. Feature Summary +- Name: +- Owner: +- Last Updated: +- Status: + +## 2. Problem Statement +- Current pain: +- Why now: + +## 3. Goals and Non-Goals +### Goals +- + +### Non-Goals +- + +## 4. Users and Primary Use Cases +- Personas: +- Core scenarios: + +## 5. Functional Requirements +1. +2. + +## 6. Non-Functional Requirements +- Reliability: +- Performance: +- Security/Compliance: +- Observability: + +## 7. Success Metrics +- Product metrics: +- Technical metrics: + +## 8. Dependencies and Constraints +- Internal dependencies: +- External dependencies: +- Constraints: + +## 9. Risks and Mitigations +- Risk: +- Mitigation: + +## 10. Acceptance Criteria +1. Given/When/Then... +2. +``` + +## `fdd.md` + +```markdown +# Functional Design Document + +## 1. Design Overview +- Scope covered: +- Assumptions: + +## 2. System Context and Boundaries +- In-scope components: +- Out-of-scope components: + +## 3. Architecture +- High-level flow: +- Context/module responsibilities: +- Supervision tree impact: + +## 4. Data Design +- Schema changes: +- Data lifecycle: +- Migration/backfill strategy: + +## 5. Interfaces and Contracts +- Internal APIs: +- External APIs/webhooks: +- Event/message formats: + +## 6. Runtime Behavior +- Process model: +- Concurrency model: +- Failure handling/retries: +- Timeouts/circuit breakers: + +## 7. Security and Compliance +- AuthN/AuthZ impact: +- Data protection: +- Audit/logging requirements: + +## 8. Observability and Operations +- Metrics: +- Logs: +- Tracing: +- Alerts/runbooks: + +## 9. Testing Strategy +- Unit: +- Integration: +- Contract: +- End-to-end: +- Load/failure: + +## 10. Open Questions +- +``` + +## `plan.md` + +```markdown +# Implementation Plan + +## Phase 0 - Alignment and Readiness +### Deliverables +- Approved requirements and design baseline + +### Tasks +- [ ] Confirm scope, assumptions, and dependencies +- [ ] Finalize acceptance criteria and test approach +- [ ] Identify rollout and rollback constraints + +### Verification +- [ ] PRD and FDD approved +- [ ] Risks have owners and mitigations + +## Phase 1 - Foundations +### Deliverables +- Core scaffolding and contracts + +### Tasks +- [ ] Implement core domain modules/contexts +- [ ] Add schema changes and safe migrations +- [ ] Establish feature flags/config and baseline telemetry + +### Verification +- [ ] Unit tests for core modules pass +- [ ] Migrations verified in staging-like environment + +## Phase 2 - Feature Implementation +### Deliverables +- End-to-end feature behavior + +### Tasks +- [ ] Implement business workflows and process interactions +- [ ] Implement external/internal interfaces +- [ ] Add error handling, retry logic, and idempotency protections + +### Verification +- [ ] Integration tests pass +- [ ] Failure-path behavior verified + +## Phase 3 - Hardening and Launch +### Deliverables +- Production readiness and release + +### Tasks +- [ ] Add dashboards, alerts, and runbook updates +- [ ] Execute load/performance and security checks +- [ ] Run staged rollout and monitor key metrics + +### Verification +- [ ] SLO/SLA criteria met +- [ ] Rollback procedure validated +- [ ] Launch sign-off recorded +``` + +## Task Authoring Rules + +- Write each task as one actionable unit of work. +- Keep tasks independently checkable. +- Add explicit verification checkboxes per phase. +- Do not mark tasks complete unless the user provides completion status. diff --git a/.codex/skills/feature-architect/references/elixir-architecture.md b/.codex/skills/feature-architect/references/elixir-architecture.md new file mode 100644 index 0000000..da1f8aa --- /dev/null +++ b/.codex/skills/feature-architect/references/elixir-architecture.md @@ -0,0 +1,67 @@ +# Elixir/OTP Architecture Guardrails + +Use this reference to keep designs aligned with BEAM strengths. + +## 1. System Decomposition + +- Organize by domain context, not by transport or framework layer alone. +- Keep context APIs explicit and stable; avoid leaking persistence concerns. +- Model long-lived concerns as supervised processes when stateful behavior is required. + +## 2. OTP Process Design + +- Prefer supervised, isolated processes over shared mutable state. +- Define restart strategy intentionally (`:one_for_one`, `:rest_for_one`, etc.). +- Bound process responsibilities; avoid "god" GenServers. +- Prefer stateless functions unless process state provides clear value. + +## 3. Concurrency and Reliability + +- Design for message ordering realities and idempotency. +- Use backpressure-aware patterns for throughput control. +- Define timeout, retry, and dead-letter behavior explicitly. +- Prefer `Task.Supervisor` or job systems for controlled async work. + +## 4. Persistence and Data Integrity + +- Keep transactional boundaries explicit in Ecto contexts. +- Use database constraints as correctness guarantees, not only app checks. +- Plan zero-downtime migrations for production paths. +- Include backfill and rollback strategies for schema evolution. + +## 5. Phoenix/Web Layer + +- Keep controllers and live views thin; move domain logic into contexts. +- Validate and normalize inputs at boundaries. +- Design APIs with explicit contracts and versioning strategy when needed. +- Use caching and pagination intentionally for read-heavy paths. + +## 6. Observability and Operability + +- Add Telemetry events for key business and system flows. +- Define essential metrics: latency, throughput, error rate, saturation. +- Include structured logs with correlation IDs. +- Document operational runbooks for failure modes and recovery. + +## 7. Security + +- Apply least privilege across service and data boundaries. +- Protect sensitive fields at rest and in transit. +- Record auditable events for security-relevant actions. +- Validate authorization decisions in domain-level workflows, not only controllers. + +## 8. Testing Expectations + +- Unit-test domain rules and pure logic heavily. +- Integration-test boundary behavior (DB, queues, APIs). +- Include property tests where invariants matter. +- Test failure and recovery paths for OTP processes. + +## 9. Architecture Decision Quality + +For each major choice, document: +- Decision +- Alternatives considered +- Tradeoffs +- Operational impact +- Migration and rollback implications diff --git a/.codex/skills/software-developer/SKILL.md b/.codex/skills/software-developer/SKILL.md new file mode 100644 index 0000000..41b13eb --- /dev/null +++ b/.codex/skills/software-developer/SKILL.md @@ -0,0 +1,64 @@ +--- +name: software-developer +description: "Execute senior-level software implementation and bug fixing. Use when Codex should either (1) take feature docs such as `prd.md`, `fdd.md`, and `plan.md` and implement end-to-end, or (2) take an informal bug/issue report, diagnose root cause, propose a targeted fix or fix plan, and proceed with implementation after user confirmation when required." +--- + +# Software Developer + +Implement production-ready changes with senior engineering rigor, from requirements to verified code. + +## Mode Selection + +Choose one mode based on input: + +1. Feature Implementation Mode +- Trigger when the user provides feature artifacts (`prd.md`, `fdd.md`, `plan.md`) or asks for end-to-end implementation. +- Prefer loading these from `docs/features//`. +- Read [references/feature-execution.md](references/feature-execution.md). + +2. Bug Fix Mode +- Trigger when the user provides an issue report, failing behavior, error logs, or regression symptoms. +- Read [references/bug-fix-playbook.md](references/bug-fix-playbook.md). + +## Feature Implementation Mode + +1. Build context +- Parse scope, acceptance criteria, constraints, and phased tasks from provided docs. +- Trace each planned task to concrete code changes and tests. +- Surface contradictions or missing requirements early. + +2. Execute end-to-end +- Implement phase-by-phase in the order defined by `plan.md`. +- Treat each phase as a cohesive set of functionality and complete it before moving to the next phase. +- If `plan.md` includes PR groups, deliver phases according to that grouping while preserving phase order. +- Keep changes minimal but complete for each phase. +- Maintain existing code style and architecture conventions. + +3. Validate and close +- Run relevant tests and linters. +- Confirm acceptance criteria are met. +- Update progress tracking checkboxes in `plan.md` for completed tasks and phases. +- Ensure the final phase (manual QA acceptance testing) is executed and recorded. + +## Bug Fix Mode + +1. Diagnose first +- Reproduce the issue when possible. +- Identify probable root cause and blast radius. +- Determine whether the fix is low-risk targeted or requires a planned multi-step approach. + +2. Choose fix path +- Targeted fix path: implement immediately when root cause is clear and blast radius is small. +- Planned fix path: present concise analysis, recommended fix, and risk/validation plan, then request confirmation before editing code. + +3. Implement and verify +- Apply the fix with minimal side effects. +- Add or update tests that fail before and pass after. +- Validate affected behavior and adjacent risk areas. + +## Delivery Standards + +- Explain assumptions and decisions concretely. +- Prefer root-cause fixes over superficial patches. +- Keep commits and diffs understandable and scoped. +- Explicitly call out what was verified and what was not verified. diff --git a/.codex/skills/software-developer/agents/openai.yaml b/.codex/skills/software-developer/agents/openai.yaml new file mode 100644 index 0000000..2f72cd3 --- /dev/null +++ b/.codex/skills/software-developer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Software Developer" + short_description: "Implement features or diagnose and fix bugs with senior rigor." + default_prompt: "Implement this feature package end-to-end, or analyze this bug and deliver a targeted fix plan and patch." diff --git a/.codex/skills/software-developer/references/bug-fix-playbook.md b/.codex/skills/software-developer/references/bug-fix-playbook.md new file mode 100644 index 0000000..fe3db09 --- /dev/null +++ b/.codex/skills/software-developer/references/bug-fix-playbook.md @@ -0,0 +1,49 @@ +# Bug Fix Playbook + +## 1. Problem Framing + +- Capture expected behavior vs actual behavior. +- Capture environment, trigger conditions, and reproducibility. +- Gather artifacts: logs, stack traces, recent changes, failing tests. + +## 2. Root Cause Analysis + +- Reproduce with the smallest reliable test case. +- Isolate fault domain (input validation, state transition, concurrency, persistence, integration, etc.). +- Determine if issue is deterministic, intermittent, or load-dependent. + +## 3. Fix Path Decision + +Use targeted fix immediately when all are true: +- Root cause is clear. +- Blast radius is small and localized. +- Validation can be completed quickly with strong confidence. + +Use planned fix with user confirmation when any are true: +- Root cause is uncertain or multi-factor. +- Fix touches multiple subsystems or risky migrations. +- Behavior tradeoffs require product or operational decision. + +## 4. Planned Fix Proposal (Before Coding) + +Provide: +- Problem summary +- Root-cause hypothesis +- Recommended fix approach +- Risks and alternatives +- Verification plan + +Request confirmation before implementing this planned path. + +## 5. Implementation and Validation + +- Implement the smallest complete fix. +- Add regression tests that would catch recurrence. +- Run targeted tests first, then broader checks as needed. +- Verify no obvious regressions in adjacent behavior. + +## 6. Completion Criteria + +- Bug behavior resolved in reproducible scenario. +- Regression test exists and passes. +- Known risks and limitations documented. diff --git a/.codex/skills/software-developer/references/feature-execution.md b/.codex/skills/software-developer/references/feature-execution.md new file mode 100644 index 0000000..a24051d --- /dev/null +++ b/.codex/skills/software-developer/references/feature-execution.md @@ -0,0 +1,39 @@ +# Feature Execution Workflow + +## 1. Intake and Alignment + +- Read `prd.md`, `fdd.md`, and `plan.md` in that order when all are available. +- Extract: +- Feature scope and non-goals +- Acceptance criteria +- NFRs (performance, reliability, security, observability) +- Phase/task checklist expectations +- Flag ambiguities that block correct implementation. + +## 2. Implementation Mapping + +- Convert each plan task into concrete code/test/document updates. +- Identify dependencies and order work by critical path. +- Preserve architecture constraints defined by `fdd.md`. + +## 3. Phase Execution + +For each phase: +- Implement required code changes. +- Add or update automated tests. +- Run relevant quality checks. +- Mark completed checklist tasks in `plan.md` (`- [x]`) only after verification. + +## 4. Verification Baseline + +- Functional criteria satisfied. +- Regression risk reviewed for touched modules. +- NFR impacts considered and addressed. +- Operational concerns captured (migrations, rollout, monitoring, rollback). + +## 5. Delivery Format + +- Summarize implemented scope. +- List files changed and why. +- Report command-based verification results. +- Note remaining risks and follow-up tasks. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b671d5e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md + +## Project Scope + +- Maintain and extend `lti_1p3`, an Elixir library implementing LTI 1.3 platform and tool flows. +- Keep the library framework-agnostic while integrating cleanly with Phoenix and other Plug-based apps. +- Prioritize protocol correctness, security guarantees, and backward-compatible public APIs. +- Support both in-memory defaults and pluggable persistence/key management for production deployments. + +## Overview + +- Primary domains: + - Tool-side launch flow (`Lti_1p3.Tool.*`) including OIDC login and launch validation. + - Platform-side launch flow (`Lti_1p3.Platform.*`) including authorization redirect and login hint handling. + - Security primitives: JWK handling, JWT validation, nonce protection, and key retrieval/caching. +- Entry points: + - `Lti_1p3` for shared operations (JWKs, key cache management). + - `Lti_1p3.Tool` and `Lti_1p3.Platform` for role-specific workflows. +- Persistence is abstracted via behavior contracts (`Lti_1p3.DataProvider`, `Lti_1p3.ToolDataProvider`, `Lti_1p3.PlatformDataProvider`). + +## Key Architectural Patterns + +- Behavior-driven boundaries: + - Core persistence and key retrieval are behind behavior contracts. + - Production adapters can be swapped without changing business logic. +- OTP supervision for key management: + - `Lti_1p3.KeyProviderSupervisor` hosts key-provider processes. + - Key cache refresh is interval-driven and configurable. +- Functional validation pipelines: + - Critical launch validation uses `with` chains and tagged tuples (`{:ok, value}` / `{:error, reason}`). + - Fail-fast execution preserves clear reason codes for callers. +- Security-first defaults: + - Nonce uniqueness checks prevent replay. + - JWT timestamp checks include modest clock-skew tolerance. + - Public key lookup is centralized through key providers. + +## Engineering Workflow + +1. Choose one planning location before starting: + - Use `docs/exec-plans/current//` for active work. + - Historical feature plans live under `docs/exec-plans/archive/features//`. + - Do not create parallel planning artifacts for the same change in both locations unless the user explicitly asks for that duplication. +2. Create feature architecture docs under the selected planning location: + - `prd.md` + - `fdd.md` + - `plan.md` +3. Treat phases in `plan.md` as cohesive functional slices and execute them in order. +4. If the feature is too large for one PR, group one or more sequential phases into PR groups that can be delivered independently. +5. During implementation, update `plan.md` checkboxes as tasks/phases are completed. +6. Keep the final phase as manual QA acceptance testing before feature completion. +7. Confirm scope and affected LTI surface area (Tool, Platform, shared security, provider contracts). +8. Read relevant behavior contracts before modifying implementations. +9. Add/update tests in `test/lti_1p3/**` for success and failure paths. +10. Run the most specific affected tests first, then run `mix test` before finalizing. +11. Run `mix compile` when touching public APIs, behaviors, specs, module names, aliases, or configuration-sensitive code paths. +12. Run `mix format` for all touched files before finalizing. +13. Update docs when behavior or integration expectations change: + +- Update `README.md` for public setup, API, or integration changes. +- Update focused docs under `docs/*.md` for behavior, telemetry, troubleshooting, or architecture changes. +- Update active planning artifacts under `docs/exec-plans/current/` as implementation progresses and move completed plans to `docs/exec-plans/archive/features/` when they become historical references. + +14. Update top-level `CHANGELOG.md` for every implemented feature or bug fix: + +- Add a high-level summary under the current unreleased release heading (`## [1.0.0] (Unreleased)` at present, until the file’s convention changes). +- Use Keep a Changelog sections (`Added`, `Changed`, `Fixed`, etc.). +- Keep entries concise and integration-focused (not line-by-line diffs). + +15. Keep migration guidance up to date whenever necessary for any changes made: + +- Add/update a `Migration Guide` section directly in `CHANGELOG.md` under the relevant release when client app migrations and/or infrastructure changes are required. +- Include: required changes and concise upgrade steps. + +## Coding Style Guidelines + +- Follow standard Elixir formatting; run `mix format` for all touched files. +- Prefer pure functions and pattern matching over nested conditionals. +- Keep public API contracts stable; use tagged tuples for recoverable errors. +- Do not change tagged tuple shapes, `reason` atoms, `stage` atoms, or public struct fields without corresponding tests, docs, changelog updates, and migration notes when appropriate. +- Reserve exceptions for misconfiguration or truly exceptional conditions. +- Keep `@doc` and `@spec` on public functions and behaviors accurate. +- Use descriptive reason atoms in error maps (example: `:invalid_registration`, `:invalid_nonce`). +- Keep modules focused by domain (`Tool`, `Platform`, `Claims`, `Roles`, `Services`, `KeyProviders`). + +## Common Development Tasks + +- Install/update deps: `mix deps.get` +- Compile: `mix compile` +- Run full test suite: `mix test` +- Run a single test file: `mix test test/lti_1p3/tool/launch_validation_test.exs` +- Run a single test line: `mix test test/lti_1p3/tool/launch_validation_test.exs:42` +- Run watcher during tight feedback loops: `mix test.watch` +- Coverage (configured alias): `mix test.coverage` +- XML coverage alias: `mix test.coverage.xml` +- Format codebase: `mix format` +- Generate docs: `mix docs` + +## Important Considerations + +- Never expose private JWK material; only publish public JWK sets. +- LTI launch correctness depends on validating `state`, nonce, JWT signature, `exp/iat`, registration, and deployment. +- Provider configuration is mandatory for real persistence; in-memory defaults are volatile and test-oriented. +- Key-provider supervision should be included in host apps that rely on key caching/refresh. +- Avoid breaking return tuple shapes or error reason atoms; downstream apps may pattern-match them. +- Keep security-sensitive defaults explicit in config (`nonce`/`login_hint` TTLs, key-cache TTL/refresh intervals). + +## Anti-Patterns To Avoid + +- Do not add app-specific Phoenix controllers, routers, or persistence assumptions to core library modules. +- Do not bypass behavior contracts for persistence, nonce handling, or key retrieval in order to “simplify” a feature. +- Do not weaken nonce, state, timestamp, audience, issuer, or deployment checks for convenience. +- Do not log or document private JWK material, bearer tokens, or other secrets in examples or troubleshooting notes. +- Do not introduce breaking public API changes silently; every integration-visible change needs matching tests and documentation. + +## Debugging Tips + +- For JWT investigation, inspect token claims/header early to confirm issuer, audience, `kid`, deployment, and message type. +- Validate key retrieval paths with cache tooling: + - `Lti_1p3.key_cache_info/0` + - `Lti_1p3.refresh_all_keys/0` + - `Lti_1p3.clear_key_cache/0` +- Reproduce launch failures by isolating each validation stage (state, registration, signature, timestamps, deployment, nonce). +- In tests, keep mocks deterministic and assert exact failure reasons, not only `{:error, _}`. +- When debugging browser-embedded launches, verify cookie/session behavior in iframe contexts. + +## LTI 1.3 Specification References + +- LTI 1.3 Core: https://site.imsglobal.org/standards/lti/lti-1p3/1p3 +- IMS Security Framework 1.0: https://www.imsglobal.org/spec/security/v1p0/ +- LTI Deep Linking 2.0: https://site.imsglobal.org/standards/lti/lti-dl/2p0 +- LTI Names and Role Provisioning Services 2.0: https://site.imsglobal.org/standards/lti/lti-nrps/2p0 +- LTI Assignment and Grade Services 2.0: https://site.imsglobal.org/standards/lti/lti-ags/2p0 +- LTI 1.3 Implementation Guide: https://www.imsglobal.org/spec/lti/v1p3/impl/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..da97d14 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,14 @@ +# Architecture + +## System Map + +`lti_1p3` is a framework-agnostic Elixir library that implements both sides of the LTI 1.3 protocol: + +- Shared entry points live in `Lti_1p3` for JWK management, key-cache operations, and common configuration. +- Tool-side flows live under `Lti_1p3.Tool.*` and cover OIDC login redirects, launch validation, message dispatch, and service clients such as AGS and NRPS. +- Platform-side flows live under `Lti_1p3.Platform.*` and cover authorization redirects, login hints, and platform launch payload generation. +- Security-sensitive validation is centralized under `Lti_1p3.Core.Validation.*` for registration, deployment, timestamps, JWTs, nonce checks, and message validation. +- Persistence and integration seams are behavior-driven. Host apps swap in durable implementations via `Lti_1p3.DataProvider` and related provider contracts without changing protocol logic. +- Public key retrieval is managed through the key-provider system (`Lti_1p3.KeyProvider`, `Lti_1p3.KeyProviderSupervisor`, and concrete providers such as `MemoryKeyProvider`). + +The library is consumed by Phoenix or other Plug applications, but it does not own HTTP endpoints, browser sessions, or database schema. Those concerns remain in host applications. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a8309a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] (Unreleased) + +### Added + +- Top-level unified APIs for tool and platform core flows: + - `Lti_1p3.Tool.login_redirect/2` + - `Lti_1p3.Tool.validate_launch/3` + - `Lti_1p3.Platform.authorize_redirect/5` +- Tool deep-linking APIs: + - `Lti_1p3.Tool.validate_deep_linking_request/1` + - `Lti_1p3.Tool.deep_linking_content_item/2` + - `Lti_1p3.Tool.build_deep_linking_response/3` +- New tool deep-linking modules for request validation, typed settings, content item building, compatibility policy hooks, response signing, and telemetry. +- Shared deep-linking claim key helper (`Lti_1p3.DeepLinking.ClaimKeys`) for cross-role reuse. +- Tool deep-linking guide (`docs/tool_deep_linking_guide.md`) and feature implementation artifacts under `docs/features/tool-deep-linking/`. +- Stage-based core validation modules for state, registration, JWT, timestamps, deployment, nonce, and message validation. +- Core telemetry events for validation stages and outcomes. +- Provider contract conformance tests and migration documentation. +- High-level integration and migration guides under `docs/`. +- Dedicated telemetry integration guide for client applications (`docs/telemetry.md`). + +### Changed + +- Tool launch validation now returns a normalized `%Lti_1p3.Tool.Launch{}` payload. +- Platform authorize flow now exposes a normalized `%Lti_1p3.Platform.AuthorizationPayload{}` payload. +- Message validator naming/path consistency fixed (`message_validators`). +- Deep-linking message dispatch now uses typed request/settings validation instead of scaffold-only checks. +- Audience and JWT validation hardening with explicit deterministic failure reasons. +- Core validation module layout was flattened from `Lti_1p3.Core.Validation.Stages.*` to `Lti_1p3.Core.Validation.*`. +- Core validation pipelines were simplified to explicit ordered stage calls (removed generic `run_stage` wrapper pattern). + +### Fixed + +- In-memory provider contract consistency and runtime implementation drift. +- Deterministic error shape alignment across core tool/platform flows. + +### Migration Guide + +Required changes: + +- None + +Upgrade steps: + +1. Update to `lti_1p3` `1.0.0`. +2. Run your normal validation suite (`mix test` and integration checks). diff --git a/README.md b/README.md index 8c609b7..10eeb3e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ defmodule MyApp.Application do end ``` -The key provider system provides intelligent caching of platform public keys with automatic refresh capabilities. For more details, see the [Key Provider System Documentation](./docs/key_provider_system.md). +The key provider system provides intelligent caching of platform public keys with automatic refresh capabilities. For more details, see the [Key Provider System Documentation](docs/key_provider_system.md). ### Jwk @@ -143,20 +143,20 @@ Before a launch can be performed, a platform must be registered with your tool b Your tool implementation will need to have 2 tool-specific endpoints for handling LTI requests. The first will be a `login` endpoint, which will issue a login request back to the platform. The second will be a `launch` endpoint, which will validate the lti launch details and if successful, display the resource. The details of both of these steps is outlined in the [LTI 1.3 Launch Overview](./docs/lti_1p3_overview.md). You will need to provide both of these endpoint urls to the platform as part of their registration process for your tool. -The first endpoint, `login`, uses the `Lti_1p3.Tool.OidcLogin` module to validate the request and return a state key and redirect_uri. For example: +The first endpoint, `login`, uses `Lti_1p3.Tool.login_redirect/2` to validate the request and return normalized redirect metadata. For example: ```elixir defmodule MyAppWeb.LtiController do use MyAppWeb, :controller def login(conn, params) do - case Lti_1p3.OidcLogin.oidc_login_redirect_url(params) do - {:ok, state, redirect_url} -> + case Lti_1p3.Tool.login_redirect(params) do + {:ok, %{state: state, redirect_url: redirect_url}} -> conn |> put_session("state", state) |> redirect(external: redirect_url) - {:error, %{reason: :invalid_registration, msg: _msg, issuer: issuer, client_id: client_id}} -> + {:error, %{reason: :invalid_registration, details: %{issuer: issuer, client_id: client_id}}} -> handle_invalid_registration(conn, issuer, client_id) {:error, %{reason: _reason, msg: msg}} -> @@ -171,7 +171,7 @@ end Notice how the returned state is stored in the session so that it can be used later in the launch request. The user is then redirected to the returned redirect_url. In the case where an error is returned, a map with the reason code, error message, and any additional data associated with the specific error is returned and can be handled accordingly. -The second endpoint, `launch`, uses the `Lti_1p3.Tool.LaunchValidation` module to validate the launch and extract the lti claims. For example: +The second endpoint, `launch`, uses `Lti_1p3.Tool.validate_launch/3` to validate the launch and return a typed `%Lti_1p3.Tool.Launch{}`. For example: ```elixir defmodule MyAppWeb.LtiController do @@ -181,14 +181,14 @@ defmodule MyAppWeb.LtiController do def launch(conn, params) do session_state = Plug.Conn.get_session(conn, "state") - case Lti_1p3.Tool.LaunchValidation.validate(params, session_state) do - {:ok, claims} -> - handle_valid_lti_1p3_launch(conn, claims) + case Lti_1p3.Tool.validate_launch(params, session_state) do + {:ok, launch} -> + handle_valid_lti_1p3_launch(conn, launch) - {:error, %{reason: :invalid_registration, msg: _msg, issuer: issuer, client_id: client_id}} -> + {:error, %{reason: :invalid_registration, details: %{issuer: issuer, client_id: client_id}}} -> handle_invalid_registration(conn, issuer, client_id) - {:error, %{reason: :invalid_deployment, msg: _msg, registration_id: registration_id, deployment_id: deployment_id}} -> + {:error, %{reason: :invalid_deployment, details: %{registration_id: registration_id, deployment_id: deployment_id}}} -> handle_invalid_deployment(conn, registration_id, deployment_id) {:error, %{reason: _reason, msg: msg}} -> @@ -201,7 +201,29 @@ defmodule MyAppWeb.LtiController do end ``` -If successful, `validate` returns the LTI claims from the request. +If successful, `validate_launch` returns a normalized launch struct with claims, message type, and deployment metadata. + +#### Tool Deep Linking Responses + +For `LtiDeepLinkingRequest` launches, use the deep-linking API to validate the deep-linking claim set, build content items, and generate a signed response JWT: + +```elixir +with {:ok, request} <- Lti_1p3.Tool.validate_deep_linking_request(launch.claims), + {:ok, item} <- + Lti_1p3.Tool.deep_linking_content_item(:lti_resource_link, %{ + "url" => "https://tool.example.com/resources/42", + "title" => "Homework 1" + }), + {:ok, %{jwt: jwt, return_url: return_url}} <- + Lti_1p3.Tool.build_deep_linking_response(request, [item]) do + submit_deep_linking_response(conn, return_url, jwt) +else + {:error, %{reason: reason, msg: msg}} -> + render(conn, "lti_error.html", reason: reason, msg: msg) +end +``` + +For full examples, see [`docs/tool_deep_linking_guide.md`](docs/tool_deep_linking_guide.md). If you are using Phoenix, don't forget to add these endpoints to your `router.ex`. The LTI 1.3 specification says the `login` request can be sent as either a `GET` or `POST`, so we must support both methods. @@ -233,7 +255,7 @@ Before your platform can initiate a launch request, you must first create a **Pl The choice of client_id here is somewhat arbitrary and can simply be an incrementing integer or guid-based. The only constraint is that it must be unique. This client_id will be provided to the tool as part of it's configuration details. -Your platform implementation will need to have an `authorize_redirect` endpoint for handling platform-specific LTI requests which will verify the current user logged in is the same user who initiated the request using the login_hint and then use the `Lti_1p3.AuthorizationRedirect` module to authorize the LTI details by verifying the LTI details provided by the tool and if successful, render a form that will post the final LTI request and params to the tool. For example: +Your platform implementation will need to have an `authorize_redirect` endpoint for handling platform-specific LTI requests which will verify the current user logged in is the same user who initiated the request using the login_hint and then use `Lti_1p3.Platform.authorize_redirect/5` to authorize and sign the launch payload. For example: ```elixir defmodule MyAppWeb.LtiController do @@ -243,13 +265,17 @@ defmodule MyAppWeb.LtiController do def authorize_redirect(conn, params) do issuer = "https://platform.example.edu" - deployment_id = "some-deployment-id" - # current user can be any map or struct that has an id: %{id: user_id} current_user = conn.assigns[:current_user] + claims = [ + Lti_1p3.Claims.MessageType.message_type(:lti_resource_link_request), + Lti_1p3.Claims.DeploymentId.deployment_id("some-deployment-id"), + Lti_1p3.Claims.TargetLinkUri.target_link_uri(params["redirect_uri"]), + Lti_1p3.Claims.Roles.roles([]) + ] - case Lti_1p3.AuthorizationRedirect.authorize_redirect(params, current_user, issuer, deployment_id) do - {:ok, redirect_uri, state, id_token} -> + case Lti_1p3.Platform.authorize_redirect(params, current_user, issuer, claims) do + {:ok, %{redirect_uri: redirect_uri, state: state, id_token: id_token}} -> conn |> render("post_redirect.html", redirect_uri: redirect_uri, state: state, id_token: id_token) diff --git a/docs/BACKEND.md b/docs/BACKEND.md new file mode 100644 index 0000000..23a6023 --- /dev/null +++ b/docs/BACKEND.md @@ -0,0 +1,13 @@ +# Backend + +## Service Architecture + +- This project is a reusable backend library rather than a running backend service. +- Business logic is organized by domain modules under `lib/lti_1p3/`, with clear separation between tool flows, platform flows, shared validation, claims, roles, and provider infrastructure. +- OTP supervision is used for key-provider lifecycle management and cache refresh behavior. + +## Backend Boundaries + +- The library owns protocol logic, validation pipelines, typed structs, and behavior contracts. +- Consuming applications own HTTP routing, session storage, durable persistence, deployment topology, and external credential management. +- The library should not grow app-specific controllers, schemas, or product-domain workflows that are unrelated to LTI interoperability. diff --git a/docs/CODEREVIEW.md b/docs/CODEREVIEW.md new file mode 100644 index 0000000..031658a --- /dev/null +++ b/docs/CODEREVIEW.md @@ -0,0 +1,13 @@ +# Code Review + +## Policy + +- Review changes with a protocol-correctness and regression mindset first. +- Prioritize bugs, security regressions, return-shape changes, missing tests, and undocumented behavior changes over stylistic preferences. +- Treat public API stability as a review gate because downstream integrations may pattern-match on exact tuples, reason atoms, and structs. + +## Review Guides + +- Check the affected LTI surface area: Tool, Platform, shared core validation, provider contracts, or key-provider infrastructure. +- Confirm that success and failure paths are both covered in tests when validation or persistence behavior changes. +- Verify docs, changelog, and migration notes when user-visible behavior or setup requirements change. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..c954c72 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,9 @@ +# Design + +## Principles + +- Prefer small, focused modules grouped by LTI domain. +- Use pure functions, pattern matching, and tagged tuples for recoverable errors. +- Centralize security validation so protocol invariants are enforced consistently. +- Keep extension points behind behaviors rather than embedding application-specific persistence or networking assumptions. +- Favor documentation and tests that make the public integration contract explicit. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..5f7dd16 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,9 @@ +# Frontend + +## UI Rules + +This repository does not contain a frontend application. + +- Browser-facing login and launch endpoints are implemented by consuming applications, typically Phoenix or another Plug stack. +- Documentation and examples should remain UI-framework agnostic and focus on protocol boundaries, request handling, and session/state requirements. +- Frontend-specific behavior that matters here is limited to launch context concerns such as iframe cookie restrictions and redirect flows documented in `README.md`. diff --git a/docs/ISSUE_TRACKING.md b/docs/ISSUE_TRACKING.md new file mode 100644 index 0000000..80760e5 --- /dev/null +++ b/docs/ISSUE_TRACKING.md @@ -0,0 +1,12 @@ +# Issue Tracking + +## System Of Record + +- No repository-local issue tracker is configured in the harness contract today. +- Until a formal tracker is documented, the practical system of record is the repository history plus active execution plans under `docs/exec-plans/current/` and archived feature work under `docs/exec-plans/archive/features/`. + +## Intake Workflow + +- Capture new feature work as a scoped work item with `prd.md`, `fdd.md`, and `plan.md` under the appropriate docs directory. +- Capture bugs with enough protocol context to reproduce them: issuer, client ID, deployment, message type, failing validation stage, and expected reason atom or return shape. +- Before implementation, identify whether the change touches Tool flows, Platform flows, shared security, or provider contracts so testing and docs updates are scoped correctly. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..91725fa --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,19 @@ +# Operations + +## Observability + +- The library emits `:telemetry` events for core validation stages and outcomes. See `docs/telemetry.md` for event names, measurements, and metadata. +- The library also logs validation summaries through `Logger`; consuming applications control log routing and verbosity. +- There is no built-in metrics backend or tracing exporter in this repository. Observability is delegated to host applications. + +## Performance + +- The main runtime performance considerations are JWT validation, outbound key retrieval, and cache-hit behavior in the key-provider subsystem. +- `Lti_1p3.KeyProviderSupervisor` and cache-management APIs exist to reduce repeated key fetches and support refresh workflows. +- Performance-sensitive changes should avoid adding unnecessary network requests or repeated cryptographic work inside validation paths. + +## Rollout + +- This repository is published as a library rather than rolled out as a standalone service. +- Release readiness means maintaining backward-compatible public APIs, documenting migration steps in `docs/migrations/` when needed, and updating `CHANGELOG.md`. +- Host applications are responsible for deploying any surrounding web endpoints, supervision trees, and durable provider implementations. diff --git a/docs/PLANS.md b/docs/PLANS.md new file mode 100644 index 0000000..85587dc --- /dev/null +++ b/docs/PLANS.md @@ -0,0 +1,5 @@ +# Plans + +## Work Item Model + +Active work lives under `docs/exec-plans/current/`. diff --git a/docs/PRODUCT_SENSE.md b/docs/PRODUCT_SENSE.md new file mode 100644 index 0000000..4bc9405 --- /dev/null +++ b/docs/PRODUCT_SENSE.md @@ -0,0 +1,11 @@ +# Product Sense + +## Product Goals + +The product is an Elixir library for teams building LTI 1.3 tools, platforms, or both. + +- Make protocol-correct LTI 1.3 flows available through stable, composable APIs. +- Keep the library framework-agnostic so Phoenix and non-Phoenix applications can integrate it cleanly. +- Preserve strong security defaults around JWT validation, nonce protection, key management, and deployment validation. +- Allow production adopters to plug in durable persistence and key-management implementations without rewriting core flow logic. +- Keep documentation practical enough that host applications can wire login, launch, AGS, NRPS, and platform redirects with low ambiguity. diff --git a/docs/QUALITY_SCORE.md b/docs/QUALITY_SCORE.md new file mode 100644 index 0000000..1934c2f --- /dev/null +++ b/docs/QUALITY_SCORE.md @@ -0,0 +1,8 @@ +# Quality Score + +## Current State + +Current quality is driven primarily by protocol correctness, API stability, and security posture rather than by an internal numeric score. + +- Strengths: broad automated coverage across tool, platform, provider, role, and key-provider flows; explicit docs for migration and telemetry; framework-agnostic architecture. +- Main quality risks: regressions in validation reason atoms, launch contract shape, key-cache behavior, or setup/documentation drift for consuming applications. diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md new file mode 100644 index 0000000..b013601 --- /dev/null +++ b/docs/RELIABILITY.md @@ -0,0 +1,8 @@ +# Reliability + +## Expectations + +- Launch validation and authorization helpers should fail deterministically with explicit reason atoms and stage metadata. +- Nonce validation, timestamp checks, and registration/deployment checks are reliability-critical because incorrect acceptance or rejection breaks interoperability. +- Key retrieval should remain resilient through caching and supervised refresh rather than repeated direct fetches on every request. +- Consuming applications are responsible for reliable session/state storage and durable provider implementations in production. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..236f8e9 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,9 @@ +# Security + +## Requirements + +- Never expose private JWK material; only publish public JWK sets. +- Preserve JWT signature validation, issuer/audience checks, `exp`/`iat` handling, nonce uniqueness, state validation, registration lookup, and deployment validation. +- Keep security-sensitive defaults explicit and documented, especially cache, nonce, and login-hint TTL behavior. +- Avoid weakening provider boundaries that allow host applications to supply secure persistence and key-management implementations. +- Security-related behavior changes require tests for both acceptance and rejection paths. diff --git a/docs/STACK.md b/docs/STACK.md new file mode 100644 index 0000000..5f01abe --- /dev/null +++ b/docs/STACK.md @@ -0,0 +1,23 @@ +# Stack + +## Languages + +- Elixir `~> 1.17` is the implementation language for the library and test suite. +- Erlang/OTP underpins supervision, process-based key providers, and runtime services. +- YAML appears only in harness metadata such as [`harness.yml`](/Users/eliknebel/Developer/lti_1p3/harness.yml). + +## Frameworks + +- Mix drives compilation, formatting, docs, and test execution. +- `Joken` provides JWT signing and validation primitives. +- `HTTPoison` is used for outbound HTTP calls such as key retrieval and service requests. +- `Jason` handles JSON serialization. +- `Timex` and `UUID` support timestamp handling and correlation identifiers. +- `:telemetry` is the observability surface emitted by the library. +- The project intentionally stays framework-agnostic, with Phoenix/Plug integration shown only through examples and guides. + +## Storage + +- The default persistence adapter is `Lti_1p3.DataProviders.MemoryProvider`, which is volatile and best suited for tests or examples. +- Production storage is delegated to pluggable provider implementations supplied by consuming applications. +- The library itself does not ship an Ecto repo, SQL schema, or migration set. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..df35ccc --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,14 @@ +# Testing + +## Test Types + +- Unit and integration-style tests live under `test/lti_1p3/**`. +- Provider contract coverage verifies behavior-driven persistence and key-provider boundaries. +- Flow tests cover tool launch validation, platform authorization redirects, service clients, roles, and helper modules. +- Failure-path assertions are important in this repository because callers pattern-match on specific reason atoms and tagged-tuple return shapes. + +## Required Gates + +- Run `mix test` for any non-trivial change. +- Run targeted tests first when iterating on a specific LTI surface area, then rerun the full suite before finalizing. +- Add or update tests for both success and failure paths whenever protocol validation, provider behavior, or security checks change. diff --git a/docs/TOOLING.md b/docs/TOOLING.md new file mode 100644 index 0000000..d9a4a85 --- /dev/null +++ b/docs/TOOLING.md @@ -0,0 +1,16 @@ +# Tooling + +## Commands + +- `mix compile` compiles the library. +- `mix test` runs the full test suite. +- `mix test path/to/file_test.exs` and `mix test path/to/file_test.exs:LINE` are the primary focused-test workflows. +- `mix format` formats touched Elixir files. +- `mix docs` generates HexDocs output. +- `mix test.coverage` and `mix test.coverage.xml` generate coverage reports through ExCoveralls. + +## Required Gates + +- New work should pass `mix format` and `mix test` before review. +- Public API changes should update docs and changelog entries, and should preserve existing tuple/error contracts unless a deliberate breaking change is planned. +- Security-sensitive changes should be validated against the relevant LTI and IMS security flows already documented in the repository. diff --git a/docs/core_migration_guide.md b/docs/core_migration_guide.md new file mode 100644 index 0000000..c8cc5ba --- /dev/null +++ b/docs/core_migration_guide.md @@ -0,0 +1,31 @@ +# Core API Migration Guide + +## Summary + +The core tool/platform APIs are now unified under `Lti_1p3.Tool` and `Lti_1p3.Platform` with standardized tuple contracts and error maps. + +## Tool changes + +- Prefer `Lti_1p3.Tool.login_redirect/2` over directly calling `Lti_1p3.Tool.OidcLogin`. +- Prefer `Lti_1p3.Tool.validate_launch/3` over directly calling `Lti_1p3.Tool.LaunchValidation`. +- Launch success now returns `%Lti_1p3.Tool.Launch{}`. + +## Platform changes + +- Prefer `Lti_1p3.Platform.authorize_redirect/5` over directly calling `Lti_1p3.Platform.AuthorizationRedirect`. +- Success now returns `%Lti_1p3.Platform.AuthorizationPayload{}`. + +## Error changes + +All core failures now follow: + +```elixir +{:error, %{reason: atom(), stage: atom(), msg: String.t(), details: map()}} +``` + +Update pattern matches to use `reason` and `stage` rather than message strings. + +## Message validators + +The typoed path `tool/message_vaildators` has been corrected to `tool/message_validators`. +Deep-linking request validation scaffolding is now included. diff --git a/docs/core_tool_platform_guide.md b/docs/core_tool_platform_guide.md new file mode 100644 index 0000000..48456ff --- /dev/null +++ b/docs/core_tool_platform_guide.md @@ -0,0 +1,75 @@ +# Core Tool + Platform Guide + +This guide documents the unified core APIs introduced for LTI 1.3 tool and platform flows. + +## Tool API + +### Build OIDC login redirect + +```elixir +{:ok, %{state: state, redirect_url: redirect_url}} = + Lti_1p3.Tool.login_redirect(params) +``` + +### Validate launch + +```elixir +{:ok, %Lti_1p3.Tool.Launch{} = launch} = + Lti_1p3.Tool.validate_launch(params, expected_state) +``` + +`Lti_1p3.Tool.validate_launch/3` returns deterministic error maps on failure: + +```elixir +{:error, %{reason: :invalid_nonce, stage: :nonce, msg: "Duplicate nonce", details: %{}}} +``` + +### Validate deep-linking request claims + +```elixir +{:ok, request} = + Lti_1p3.Tool.validate_deep_linking_request(launch.claims) +``` + +### Build and sign a deep-linking response + +```elixir +{:ok, item} = + Lti_1p3.Tool.deep_linking_content_item(:lti_resource_link, %{ + "url" => "https://tool.example.com/resources/42", + "title" => "Resource" + }) + +{:ok, %{jwt: jwt, return_url: return_url}} = + Lti_1p3.Tool.build_deep_linking_response(request, [item]) +``` + +## Platform API + +### Authorize redirect + +```elixir +{:ok, %Lti_1p3.Platform.AuthorizationPayload{} = payload} = + Lti_1p3.Platform.authorize_redirect(params, current_user, issuer, claims) +``` + +`payload` contains `redirect_uri`, `state`, and signed `id_token`. + +## Error Contract + +Core APIs use the same error shape: + +```elixir +%{reason: atom(), stage: atom(), msg: String.t(), details: map()} +``` + +## Telemetry + +Core validation emits: + +- `[:lti_1p3, :core, :validation, :stage]` +- `[:lti_1p3, :core, :validation, :outcome]` + +Both include flow metadata (`:tool_launch` or `:platform_authorize_redirect`) and result (`:ok` / `:error`). + +For full client integration details, see [Telemetry](telemetry.md). diff --git a/docs/core_troubleshooting.md b/docs/core_troubleshooting.md new file mode 100644 index 0000000..b3daae8 --- /dev/null +++ b/docs/core_troubleshooting.md @@ -0,0 +1,26 @@ +# Core Troubleshooting + +## `invalid_oidc_state` + +- Ensure session state is persisted between login and launch. +- Verify cookie behavior in iframe contexts. + +## `invalid_registration` + +- Confirm `iss` and `aud` resolve to an existing tool registration. +- Verify client ID and issuer values exactly match stored registration values. + +## `invalid_audience` + +- `aud` must contain expected client ID. +- For multi-audience tokens, `azp` must equal expected client ID. + +## `signature_error` / key resolution errors + +- Validate `kid` is present in JWT header. +- Confirm key set URL is reachable and includes matching `kid`. + +## `invalid_nonce` + +- Nonce reuse is rejected by design. +- Check retry/replay behavior in caller code. diff --git a/docs/design-docs/core-beliefs.md b/docs/design-docs/core-beliefs.md new file mode 100644 index 0000000..0ea4e78 --- /dev/null +++ b/docs/design-docs/core-beliefs.md @@ -0,0 +1,7 @@ +# Core Beliefs + +- Protocol correctness is a product feature, not an implementation detail. +- Security-sensitive behavior should be explicit, centralized, and regression-tested. +- Public APIs should remain stable and predictable for consuming applications. +- Framework-agnostic design matters because this library is intended to fit Phoenix and other Plug-based stacks cleanly. +- Pluggable behaviors are preferable to hard-coded persistence or infrastructure choices. diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md new file mode 100644 index 0000000..522b712 --- /dev/null +++ b/docs/design-docs/index.md @@ -0,0 +1,6 @@ +# Design Docs Index + +Use this directory for design docs tied to work items that need slice-level implementation detail beyond `prd.md`, `fdd.md`, and `plan.md`. + +- Keep docs scoped to one feature slice or architectural decision. +- Link the relevant work item directory under `docs/exec-plans/current/` or `docs/exec-plans/archive/features/` when applicable. diff --git a/docs/exec-plans/archive/features/ags/fdd.md b/docs/exec-plans/archive/features/ags/fdd.md new file mode 100644 index 0000000..71b4d25 --- /dev/null +++ b/docs/exec-plans/archive/features/ags/fdd.md @@ -0,0 +1,108 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Complete AGS tool client surface, claim modeling, scope enforcement, and observability. +- Assumptions: + - Core API refactor is available or implemented first. + - HTTP client remains configurable via `Lti_1p3.Config`. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Tool.Services.AGS` public API redesign. + - AGS structs: endpoint config, line item, score, result page. + - Scope validator and error normalizer. +- Out-of-scope components: + - Long-term storage of grades. + - Platform-side AGS server implementation. + +## 3. Architecture +- High-level flow: + - Parse launch AGS claim -> validate scope -> build request -> execute HTTP -> decode/normalize response. +- Context/module responsibilities: + - `Lti_1p3.Services.AGS`: operation entry points. + - `Lti_1p3.Services.AGS.ScopePolicy`: scope-to-operation policy map. + - `Lti_1p3.Services.AGS.Client`: HTTP and media-type handling. + - `Lti_1p3.Services.AGS.Parser`: response decoding and pagination parsing. +- Supervision tree impact: + - No new long-lived processes required. + +## 4. Data Design +- Schema changes: + - None in library core; optional metadata may be persisted by host app. +- Data lifecycle: + - Access token is caller-supplied and short-lived. + - AGS structs are ephemeral request/response data. +- Migration/backfill strategy: + - Replace existing AGS structs with compatible field names where practical; provide migration notes for renamed fields. + +## 5. Interfaces and Contracts +- Internal APIs: + - `from_launch_claim(claim_map) -> {:ok, %AgsEndpoint{}} | {:error, error}` + - `list_line_items(endpoint, token, opts) -> {:ok, %Page{items: [...], next: ...}} | {:error, error}` + - `create_line_item(endpoint, token, attrs) -> {:ok, %LineItem{}} | {:error, error}` + - `update_line_item(line_item_url, token, attrs) -> {:ok, %LineItem{}} | {:error, error}` + - `delete_line_item(line_item_url, token) -> :ok | {:error, error}` + - `post_score(line_item_url, token, %Score{}) -> :ok | {:error, error}` + - `list_results(line_item_url, token, opts) -> {:ok, %Page{items: [%Result{}]}} | {:error, error}` +- External APIs/webhooks: + - AGS HTTP endpoints from LMS (lineitems, scores, results). +- Event/message formats: + - Error map includes `%{reason:, operation:, http_status:, retryable:, msg:}`. + +## 6. Runtime Behavior +- Process model: + - Stateless function calls per AGS operation. +- Concurrency model: + - Safe for concurrent calls from caller processes. +- Failure handling/retries: + - Retry policy optional and caller-configurable; defaults to no automatic write retry. +- Timeouts/circuit breakers: + - Configurable request timeout and bounded retry delay. + +## 7. Security and Compliance +- AuthN/AuthZ impact: + - Enforce token scope per operation before HTTP call. +- Data protection: + - Redact bearer tokens from logs and telemetry metadata. +- Audit/logging requirements: + - Log operation names, endpoint host, status class, and reason category. + +## 8. Observability and Operations +- Metrics: + - `ags.request.count`, `ags.request.duration`, `ags.request.error_count`, `ags.scope.denied_count`. +- Logs: + - Structured logs with operation and LMS host. +- Tracing: + - Telemetry spans around each outbound AGS request. +- Alerts/runbooks: + - Alert on sustained 5xx spikes or scope denial spikes. + +## 9. Testing Strategy +- Unit: + - Scope policy, media-type headers, error mapping, pagination parser. +- Integration: + - Mocked LMS responses for all operations and edge cases. +- Contract: + - Ensure operation-to-scope matrix tests are exhaustive. +- End-to-end: + - Sample flow: launch -> token -> line item -> score -> results. +- Load/failure: + - Repeated posting/list operations with intermittent failures. + +## 10. Documentation Strategy +- ExDoc requirements: + - Document all public AGS modules/functions with `@moduledoc`, `@doc`, and `@spec`. + - Add examples for line item lifecycle, score publishing, and results retrieval. +- Supporting docs: + - Keep AGS setup, scope mapping, LMS compatibility notes, and troubleshooting guides under `docs/`. +- Verification: + - `mix docs` generation and docs completeness checks are required for merge. + +## 11. Decisions +1. Optional streaming API for paged result traversal: +Decision: No. +Implementation impact: AGS will provide page-based (`list_results/3`) and eager aggregation paths only; no lazy `Stream`/`Enumerable` API will be added in this feature set. + +2. Vendor compatibility policy centralization: +Decision: Yes. +Implementation impact: LMS-specific compatibility behavior will be centralized in a pluggable policy module (`Lti_1p3.Services.AGS.CompatibilityPolicy`) with a default strict policy and optional vendor profiles selected by configuration or runtime context. diff --git a/docs/exec-plans/archive/features/ags/plan.md b/docs/exec-plans/archive/features/ags/plan.md new file mode 100644 index 0000000..70b57e4 --- /dev/null +++ b/docs/exec-plans/archive/features/ags/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - AGS Conformance Baseline +### Deliverables +- AGS requirement matrix and operation/scope map. + +### Tasks +- [ ] Build AGS protocol matrix (lineitems, scores, results, required media types/scopes). +- [ ] Audit current AGS module behavior and identify incompatibilities. +- [ ] Define final AGS API signatures and error map schema. +- [ ] Build AGS documentation inventory for public APIs and required guides under `docs/`. + +### Verification +- [ ] AGS matrix reviewed and approved. +- [ ] API signatures approved. + +## Phase 1 - AGS API and Model Refactor +### Deliverables +- New AGS typed models and operation functions. + +### Tasks +- [ ] Implement endpoint/lineitem/score/result/page structs. +- [ ] Implement claim parsing and validation helpers. +- [ ] Implement scope policy module and preflight scope checks. +- [ ] Replace string-based errors with structured reason maps. + +### Verification +- [ ] Unit tests for models and scope policy pass. +- [ ] Existing AGS tests migrated to new API. + +## Phase 2 - Full Operation Coverage +### Deliverables +- Complete AGS CRUD + score + results operation support. + +### Tasks +- [ ] Implement list/create/update/delete line item operations. +- [ ] Implement score publish with payload validation. +- [ ] Implement results retrieval with pagination traversal. +- [ ] Add retry classification and timeout handling. + +### Verification +- [ ] Integration tests pass for success and failure cases. +- [ ] Pagination and scope enforcement tests pass. + +## Phase 3 - Hardening and Documentation +### Deliverables +- Production-ready AGS observability and docs. + +### Tasks +- [ ] Add telemetry events and structured logs for AGS operations. +- [ ] Add compatibility notes for LMS-specific AGS quirks. +- [ ] Update README/docs with end-to-end AGS examples. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage for all public AGS modules/functions. + +### Verification +- [ ] Telemetry assertions pass in tests. +- [ ] Docs examples validated in test environment. +- [ ] `mix docs` completes and AGS guides under `docs/` are up to date. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual AGS interoperability report. + +### Tasks +- [ ] Validate line item lifecycle against at least 2 LMS sandboxes. +- [ ] Validate score publish and result retrieval flows. +- [ ] Verify negative paths (invalid scope, token expiry, malformed payload). +- [ ] Record pass/fail outcomes and follow-up remediation. + +### Verification +- [ ] Manual QA report approved. +- [ ] Blocking issues captured with owners. diff --git a/docs/exec-plans/archive/features/ags/prd.md b/docs/exec-plans/archive/features/ags/prd.md new file mode 100644 index 0000000..ac5dfa3 --- /dev/null +++ b/docs/exec-plans/archive/features/ags/prd.md @@ -0,0 +1,83 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: LTI AGS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-03 +- Status: Proposed + +## 2. Problem Statement +- Current pain: AGS support exists but is partial and not fully spec-complete for robust production interoperability. +- Why now: Full LTI service coverage and certification readiness requires complete AGS behavior for tools and predictable integration points for platforms. + +### Current State Analysis +- `Lti_1p3.Tool.Services.AGS` provides key operations but returns string errors with limited typed detail. +- Scope handling exists but contract does not uniformly enforce readonly/write scope distinctions for each endpoint. +- Result retrieval (`result.readonly`) behavior is missing from the current API. +- Pagination/link header handling and idempotent retry strategies are not explicitly modeled. +- Current tests emphasize happy path and basic failures but do not cover full AGS protocol matrix. + +## 3. Goals and Non-Goals +### Goals +- Implement complete AGS 2.0 tool-side client features: line items, scores, and results. +- Provide structured request/response/error contracts with predictable tuple shapes. +- Add platform-facing helpers for validating AGS claims/scopes in launch context. +- Ensure AGS APIs align with the new unified core API style. +- Require comprehensive ExDoc and supporting AGS integration guides under `docs/`. + +### Non-Goals +- Building a hosted gradebook or persistence store for AGS resources. +- Implementing LMS-specific custom AGS extensions. + +## 4. Users and Primary Use Cases +- Personas: Tool developers posting scores; tool developers reading grade context; platform integrators validating AGS claims. +- Core scenarios: + - Tool creates or finds a line item and posts user scores. + - Tool retrieves line items/results with filtering and pagination. + - Platform validates that AGS claim scopes match authorized capabilities. + +## 5. Functional Requirements +1. Support AGS claim parsing into typed structs (endpoint, scopes, lineitem(s) URLs). +2. Support line item create/read/update/delete and list operations. +3. Support score publishing with spec-compliant media types and payload validation. +4. Support results retrieval (`result.readonly`) with pagination traversal. +5. Enforce scope requirements per operation and return explicit authorization errors. +6. Provide idempotent retry hooks and explicit transient/permanent failure categories. +7. Normalize API errors as reasoned maps instead of plain strings. +8. Add compatibility options for common LMS quirks (query limits, URL path variants). +9. Document all AGS public modules/functions with `@moduledoc`, `@doc`, `@spec`, and examples for main flows. +10. Publish/update AGS setup, scope, and interoperability guides under `docs/`. + +## 6. Non-Functional Requirements +- Reliability: AGS operations must safely retry transient HTTP failures without duplicate side effects where applicable. +- Performance: P95 AGS client call overhead (excluding network) < 10ms. +- Security/Compliance: Access token scopes enforced per operation; no token leakage in logs. +- Observability: Telemetry per AGS operation with status, latency, LMS host, and reason category. +- Documentation: 100% AGS public API coverage in ExDoc and complete AGS guides in `docs/`. + +## 7. Success Metrics +- Product metrics: + - Pass AGS conformance scenarios in certification test plan. + - Successful interoperability with at least 2 major LMS sandboxes. +- Technical metrics: + - >= 90% AGS module coverage including error branches. + - 100% operation-to-scope mapping tests passing. + +## 8. Dependencies and Constraints +- Internal dependencies: Core API unification, access token module, shared HTTP client abstraction. +- External dependencies: LMS AGS endpoint availability and behavior differences. +- Constraints: Must remain framework-agnostic and persistence-agnostic. + +## 9. Risks and Mitigations +- Risk: LMS-specific AGS endpoint inconsistencies cause brittle integrations. +- Mitigation: Add tolerant URL handling and configurable adapters for known quirks. +- Risk: Retry logic causes duplicate writes. +- Mitigation: Add idempotency guidance and caller-controlled retry policy for write operations. + +## 10. Acceptance Criteria +1. Given valid AGS claim + scopes, when line item operations are invoked, then functions return typed success tuples and spec-compliant requests. +2. Given missing or insufficient scopes, when operation is invoked, then function returns `{:error, %{reason: :insufficient_scope, ...}}`. +3. Given valid scores payload and token, when score publish is invoked, then LMS response is normalized to a typed result. +4. Given results endpoint availability, when retrieving results, then pagination is supported and all pages can be consumed. +5. Given AGS failures (4xx/5xx/timeout), when operations fail, then error categories and retryability flags are explicit. +6. Given `mix docs` runs, when AGS docs are generated, then all public AGS modules/APIs and supporting guides are present and up to date. diff --git a/docs/exec-plans/archive/features/certification/fdd.md b/docs/exec-plans/archive/features/certification/fdd.md new file mode 100644 index 0000000..a372611 --- /dev/null +++ b/docs/exec-plans/archive/features/certification/fdd.md @@ -0,0 +1,103 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Certification readiness architecture, conformance harness, QA workflow, and release gating. +- Assumptions: + - Domain features (core/AGS/NRPS/deep-linking) are implemented or in-flight with stable APIs. + - CI environment can run full test matrix and preserve artifacts. + +## 2. System Context and Boundaries +- In-scope components: + - Conformance matrix artifacts under docs. + - Domain-tagged automated test suites. + - Manual QA scripts/checklists and certification dossier template. + - CI certification pipeline configuration and release gates. +- Out-of-scope components: + - External 1EdTech portal submission workflow. + - Vendor contractual/legal process handling. + +## 3. Architecture +- High-level flow: + - Spec requirement catalog -> test mapping -> automated run -> manual validation -> artifact package -> release gate decision. +- Context/module responsibilities: + - `docs/certification/conformance-matrix.md`: requirement mapping source of truth. + - `test/conformance/**`: automated conformance suites by domain. + - `docs/certification/manual-qa/**`: manual scripts and result templates. + - CI job `certification_profile`: runs domain suites, coverage, and artifacts. +- Supervision tree impact: + - None. + +## 4. Data Design +- Schema changes: + - None required. +- Data lifecycle: + - Conformance outputs stored as CI artifacts and versioned docs snapshots. +- Migration/backfill strategy: + - Introduce matrix incrementally, backfilling existing tests into requirement mappings. + +## 5. Interfaces and Contracts +- Internal APIs: + - Conformance helper macros/tags for spec requirement IDs. + - Test metadata contract: each test references requirement ID(s). +- External APIs/webhooks: + - Optional LMS sandbox endpoints used in manual QA. +- Event/message formats: + - CI summary JSON: domain, pass/fail counts, coverage, blocking failures. + +## 6. Runtime Behavior +- Process model: + - Test execution in standard ExUnit and CI workflows. +- Concurrency model: + - Parallel test execution where deterministic; isolated state for conformance tests. +- Failure handling/retries: + - Retries only for flaky external sandbox integration jobs, never for deterministic local conformance tests. +- Timeouts/circuit breakers: + - Explicit suite-level timeouts and sandbox operation timeout budgets. + +## 7. Security and Compliance +- AuthN/AuthZ impact: + - Include explicit security conformance tests for signature, nonce, scope, and claim validation. +- Data protection: + - Redact secrets/tokens from logs and artifacts. +- Audit/logging requirements: + - Keep signed build metadata and immutable artifact references for each certification candidate run. + +## 8. Observability and Operations +- Metrics: + - `conformance.pass_rate`, `conformance.requirement_coverage`, `certification.gate_status`. +- Logs: + - CI summary logs with failing requirement IDs. +- Tracing: + - Not required. +- Alerts/runbooks: + - Alert on gate failure in release branches and provide runbook for triage. + +## 9. Testing Strategy +- Unit: + - Requirement tag helpers and matrix consistency checks. +- Integration: + - End-to-end domain suites in certification profile. +- Contract: + - Verify every requirement ID has at least one test/manual verification reference. +- End-to-end: + - Full certification dry-run pipeline including artifact packaging. +- Load/failure: + - Stress test selected launch/service flows for stability evidence. + +## 10. Documentation Strategy +- ExDoc requirements: + - Certification gate enforces `@moduledoc`/`@doc`/`@spec` coverage for all public modules/functions. + - Public API examples must be present for top-level Tool/Platform and service entry points. +- Supporting docs: + - Required docs sets (integration guides, migration guides, troubleshooting, compatibility matrix) must exist under `docs/`. +- Verification: + - Certification profile includes `mix docs` and documentation completeness checks as gating steps. + +## 11. Decisions +1. Primary LMS certification evidence targets: +Decision: Canvas and Moodle. +Implementation impact: certification scripts, compatibility matrix entries, and manual QA evidence collection must prioritize Canvas and Moodle environments. + +2. Certification profile scheduling: +Decision: Run certification profiles on release branches only. +Implementation impact: CI configuration will gate release branches with certification jobs; nightly scheduled runs are out of scope for this feature set. diff --git a/docs/exec-plans/archive/features/certification/plan.md b/docs/exec-plans/archive/features/certification/plan.md new file mode 100644 index 0000000..221b52b --- /dev/null +++ b/docs/exec-plans/archive/features/certification/plan.md @@ -0,0 +1,78 @@ +# Implementation Plan + +## Phase 0 - Certification Framework Definition +### Deliverables +- Certification framework baseline and requirement ID catalog. + +### Tasks +- [ ] Define requirement ID taxonomy for core/AGS/NRPS/deep-linking/security. +- [ ] Create conformance matrix template and evidence model. +- [ ] Define certification candidate release gate policy. +- [ ] Define documentation gate policy requiring full ExDoc coverage and required guides under `docs/`. + +### Verification +- [ ] Framework and gate policy approved. +- [ ] Requirement ID catalog published. + +## Phase 1 - Conformance Mapping and Suite Structuring +### Deliverables +- Requirement-to-test mappings and reorganized test suites. + +### Tasks +- [ ] Map existing tests to requirement IDs and identify coverage gaps. +- [ ] Create `test/conformance` structure by domain. +- [ ] Add metadata/tag conventions to enforce requirement linkage. +- [ ] Fill high-priority automated test gaps blocking certification. +- [ ] Map all public modules/APIs to documentation coverage checks and required guides. + +### Verification +- [ ] All in-scope requirements mapped or flagged with owner/date. +- [ ] Conformance suites run locally and in CI. + +## Phase 2 - Manual QA and Interop Artifacts +### Deliverables +- Manual certification scripts and reproducible evidence templates. + +### Tasks +- [ ] Author manual QA scripts per domain and negative security scenarios. +- [ ] Create evidence capture templates for sandbox runs. +- [ ] Build LMS compatibility matrix and known deviations log. + +### Verification +- [ ] Manual scripts reviewed and dry-run completed. +- [ ] Compatibility matrix published. + +## Phase 3 - CI Certification Profile and Dossier +### Deliverables +- Automated certification profile and report artifacts. + +### Tasks +- [ ] Implement CI certification job with conformance suites + coverage. +- [ ] Generate machine-readable and human-readable run summaries. +- [ ] Add certification dossier template with links to artifacts and architecture/security notes. +- [ ] Add runbook for failed gate triage. +- [ ] Run `mix docs` and documentation completeness checks as part of certification CI gate. + +### Verification +- [ ] Certification CI profile passes on candidate branch. +- [ ] Dossier template complete and reproducible. +- [ ] Certification profile fails if any public API docs or required `docs/` guides are missing. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- End-to-end certification readiness sign-off report. + +### Tasks +- [ ] Execute full manual QA scripts against target LMS sandboxes. +- [ ] Execute full certification CI profile on release-candidate build. +- [ ] Review unresolved defects against gate policy. +- [ ] Produce final readiness decision document. + +### Verification +- [ ] Sign-off report approved by maintainers. +- [ ] Go/No-Go decision recorded with evidence links. + +## PR Grouping +- PR Group 1: Phases 0-1 +- PR Group 2: Phase 2 +- PR Group 3: Phases 3-4 diff --git a/docs/exec-plans/archive/features/certification/prd.md b/docs/exec-plans/archive/features/certification/prd.md new file mode 100644 index 0000000..cc13849 --- /dev/null +++ b/docs/exec-plans/archive/features/certification/prd.md @@ -0,0 +1,81 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: LTI 1.3 Certification Program Readiness +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-03 +- Status: Proposed + +## 2. Problem Statement +- Current pain: The library has substantial functionality but lacks a formalized conformance program, traceable requirement mapping, and certification execution readiness. +- Why now: Project goal is full LTI 1.3 + services support and eventual external certification. + +### Current State Analysis +- Existing tests are strong in selected areas but are not organized as a certification-oriented conformance suite. +- No single source-of-truth matrix maps spec clauses to implementation and automated/manual evidence. +- API inconsistencies and partial service implementations currently block a clean certification narrative. +- Operational artifacts (runbooks, compatibility matrix, release gates) are not yet structured for certification cycles. + +## 3. Goals and Non-Goals +### Goals +- Establish certification-grade conformance program across core, AGS, NRPS, and Deep Linking. +- Deliver deterministic test harnesses, manual scripts, and evidence capture templates. +- Define release gates and quality bars for “certification candidate” builds. +- Ensure documentation provides integrator-ready, simple API examples for tool and platform roles. +- Enforce documentation quality gates: all public modules/APIs documented for ExDoc and all supporting guides maintained under `docs/`. + +### Non-Goals +- Completing third-party certification submission itself in this feature. +- Owning LMS vendor support SLAs. + +## 4. Users and Primary Use Cases +- Personas: Library maintainers preparing certification; QA engineers executing conformance tests; integrators evaluating readiness. +- Core scenarios: + - Maintainer runs full conformance suite and receives pass/fail with traceability. + - QA executes manual interoperability scripts against LMS sandboxes. + - Release manager checks certification gate criteria before publishing. + +## 5. Functional Requirements +1. Create full conformance matrix mapping spec requirements to tests and evidence. +2. Build automated certification test suite structure by domain (core/AGS/NRPS/deep-linking). +3. Add manual certification scripts and result capture templates. +4. Define and implement certification readiness CI job/profile. +5. Define release gate policy (coverage, pass rate, critical defect threshold, docs completeness). +6. Publish compatibility matrix for tested LMS versions and known deviations. +7. Produce certification dossier template including architecture/security notes and test evidence links. +8. Add onboarding docs for running certification workflows locally and in CI. +9. Add a documentation conformance matrix that maps each public module/API to ExDoc coverage and guide references. + +## 6. Non-Functional Requirements +- Reliability: Certification suite runs deterministically with stable fixtures. +- Performance: Full certification profile execution time target under agreed CI budget. +- Security/Compliance: Security-sensitive negative tests required (signature, nonce replay, scope misuse). +- Observability: CI artifacts include machine-readable results and human-readable summary. +- Documentation: Certification-candidate builds fail if ExDoc coverage or required guides under `docs/` are incomplete. + +## 7. Success Metrics +- Product metrics: + - 100% mapped requirements for core + AGS + NRPS + Deep Linking. + - Certification-candidate release checklist completed for target version. +- Technical metrics: + - Conformance suite pass rate 100% for gating branch. + - Manual QA script completion with documented evidence for all required scenarios. + +## 8. Dependencies and Constraints +- Internal dependencies: Completion of core, AGS, NRPS, deep-linking feature sets. +- External dependencies: LMS sandbox availability and 1EdTech certification process details. +- Constraints: Certification evidence must be reproducible across environments. + +## 9. Risks and Mitigations +- Risk: Late-found conformance gaps delay certification. +- Mitigation: Incremental conformance gates per domain and early dry runs. +- Risk: Sandbox instability causes flaky evidence. +- Mitigation: Maintain multiple sandbox targets and deterministic local simulators. + +## 10. Acceptance Criteria +1. Given the conformance matrix, when reviewed, then every in-scope spec requirement has mapped implementation and verification evidence. +2. Given certification CI profile, when executed, then all domain suites pass and produce archived artifacts. +3. Given manual QA scripts, when run against target LMS sandboxes, then results are recorded and reproducible. +4. Given release candidate build, when evaluated against gate policy, then pass/fail decision is unambiguous. +5. Given integrator docs, when following examples, then tool and platform baseline integrations work without hidden steps. +6. Given documentation gates run, when a public module/API lacks docs or required guides are missing, then certification candidate build fails. diff --git a/docs/exec-plans/archive/features/core/api_error_catalog.md b/docs/exec-plans/archive/features/core/api_error_catalog.md new file mode 100644 index 0000000..37b29b8 --- /dev/null +++ b/docs/exec-plans/archive/features/core/api_error_catalog.md @@ -0,0 +1,55 @@ +# Core API + Error Catalog + +## Target Public APIs + +- `Lti_1p3.Tool.login_redirect/2` +- `Lti_1p3.Tool.validate_launch/3` +- `Lti_1p3.Platform.authorize_redirect/5` + +## Success Shapes + +- Tool launch: + - `{:ok, %Lti_1p3.Tool.Launch{...}}` +- Platform authorize redirect: + - `{:ok, %Lti_1p3.Platform.AuthorizationPayload{...}}` + +## Error Shape + +```elixir +{:error, %{reason: atom(), stage: atom(), msg: String.t(), details: map()}} +``` + +## Stage Catalog + +- Tool launch stages: + - `:state`, `:registration`, `:jwt`, `:timestamps`, `:deployment`, `:message`, `:nonce` +- Platform authorize stages: + - `:platform_registration`, `:oidc_params`, `:scope`, `:user`, `:client`, `:redirect`, `:nonce`, `:signing`, `:token_build`, `:claims` + +## Reason Atom Catalog (core) + +- `:invalid_oidc_state` +- `:missing_param` +- `:token_malformed` +- `:invalid_registration` +- `:missing_jwt_alg` +- `:invalid_jwt_alg` +- `:missing_kid` +- `:key_not_found` +- `:signature_error` +- `:invalid_issuer` +- `:invalid_audience` +- `:invalid_jwt_timestamp` +- `:invalid_deployment` +- `:invalid_message_type` +- `:invalid_message` +- `:invalid_deep_linking_request` +- `:invalid_nonce` +- `:client_not_registered` +- `:invalid_oidc_params` +- `:invalid_oidc_scope` +- `:invalid_login_hint` +- `:unauthorized_client` +- `:unauthorized_redirect_uri` +- `:missing_required_claims` +- `:token_build_failed` diff --git a/docs/exec-plans/archive/features/core/conformance_matrix.md b/docs/exec-plans/archive/features/core/conformance_matrix.md new file mode 100644 index 0000000..fe2b4f6 --- /dev/null +++ b/docs/exec-plans/archive/features/core/conformance_matrix.md @@ -0,0 +1,16 @@ +# Core Conformance Matrix + +| Requirement | Module(s) | Status | +| --- | --- | --- | +| Unified tool login + launch APIs | `Lti_1p3.Tool`, `Lti_1p3.Tool.OidcLogin`, `Lti_1p3.Tool.LaunchValidation` | Implemented | +| Unified platform authorize API | `Lti_1p3.Platform`, `Lti_1p3.Platform.AuthorizationRedirect` | Implemented | +| Canonical error map (`reason/stage/msg/details`) | `Lti_1p3.Core.Errors`, core tool/platform flows | Implemented | +| Stage-based launch validation | `Lti_1p3.Core.Validation.*` | Implemented | +| Issuer/audience/time hardening | `Lti_1p3.Core.Validation.Jwt`, `...Timestamps` | Implemented | +| Nonce replay protection with deterministic reason | `Lti_1p3.Core.Validation.Nonce` | Implemented | +| Resource launch message validation | `Lti_1p3.Tool.MessageValidators.ResourceMessageValidator` | Implemented | +| Deep-linking request validation scaffold | `Lti_1p3.Tool.MessageValidators.DeepLinkingMessageValidator` | Implemented | +| Provider contract alignment | `Lti_1p3.DataProvider`, `Lti_1p3.DataProviders.MemoryProvider` | Implemented | +| Provider contract tests | `test/lti_1p3/provider_contracts_test.exs` | Implemented | +| Stage/outcome telemetry events | `Lti_1p3.Core.Telemetry`, tool/platform tests | Implemented | +| Documentation and migration guides | `README.md`, `docs/*.md` | Implemented | diff --git a/docs/exec-plans/archive/features/core/documentation_inventory.md b/docs/exec-plans/archive/features/core/documentation_inventory.md new file mode 100644 index 0000000..3fe9d96 --- /dev/null +++ b/docs/exec-plans/archive/features/core/documentation_inventory.md @@ -0,0 +1,24 @@ +# Documentation Inventory + +## Updated + +- `README.md` +- `docs/exec-plans/archive/features/core/plan.md` +- `docs/exec-plans/archive/features/core/conformance_matrix.md` +- `docs/exec-plans/archive/features/core/api_error_catalog.md` + +## Added + +- `docs/core_tool_platform_guide.md` +- `docs/core_migration_guide.md` +- `docs/provider_adapter_migration.md` +- `docs/core_troubleshooting.md` + +## Public API docs covered in code + +- `Lti_1p3.Tool` +- `Lti_1p3.Platform` +- `Lti_1p3.Tool.Launch` +- `Lti_1p3.Platform.AuthorizationPayload` +- `Lti_1p3.Core.Errors` +- `Lti_1p3.Core.Telemetry` diff --git a/docs/exec-plans/archive/features/core/fdd.md b/docs/exec-plans/archive/features/core/fdd.md new file mode 100644 index 0000000..fc24bf3 --- /dev/null +++ b/docs/exec-plans/archive/features/core/fdd.md @@ -0,0 +1,123 @@ +# Functional Design Document + +## 1. Design Overview + +- Scope covered: Core tool/platform LTI 1.3 launch flows, API unification, provider contract alignment, and security hardening. +- Assumptions: + - Backward compatibility can be broken in favor of clean API. + - Existing provider adapters can be updated to match new behavior contracts. + +## 2. System Context and Boundaries + +- In-scope components: + - `Lti_1p3.Tool` and `Lti_1p3.Platform` top-level APIs. + - Launch/auth validation pipelines. + - Provider behavior contracts and in-memory provider reference implementation. + - Key provider integration points and telemetry. +- Out-of-scope components: + - AGS/NRPS/Deep Linking service-specific protocol operations. + - Framework-specific controllers/views. + +## 3. Architecture + +- High-level flow: + - Tool: `login_request_validate -> auth_redirect_build -> launch_validate -> normalized_launch`. + - Platform: `auth_request_validate -> claim_assembly -> id_token_sign -> redirect_payload`. +- Context/module responsibilities: + - `Lti_1p3.Tool`: public functional API for tool-side operations. + - `Lti_1p3.Platform`: public functional API for platform-side operations. + - `Lti_1p3.Core.Validation`: shared validators (state, issuer, audience, timestamps, nonce, deployment). + - `Lti_1p3.Core.Claims`: claim extraction, normalization, typed structs. + - `Lti_1p3.Core.Errors`: canonical error map builders and reason atoms. + - `Lti_1p3.ProviderContracts`: conformance helpers for behavior implementations. +- Supervision tree impact: + - Keep key provider under `Lti_1p3.KeyProviderSupervisor`. + - Convert in-memory data provider to either pure Agent module or proper GenServer (choose one, document rationale). + +## 4. Data Design + +- Schema changes: + - No required DB schema in library core; behavior contracts may require additional metadata fields in adapter stores. +- Data lifecycle: + - Nonce/login_hint entries remain TTL-bound and cleanup-capable. + - Launch validation does not persist launch payload by default. +- Migration/backfill strategy: + - Provide adapter migration guide for updated behavior callback signatures and error shapes. + +## 5. Interfaces and Contracts + +- Internal APIs: + - `Lti_1p3.Tool.login_redirect(params, opts)` -> `{:ok, %{state: ..., redirect_url: ...}} | {:error, error}` + - `Lti_1p3.Tool.validate_launch(params, expected_state, opts)` -> `{:ok, %Tool.Launch{}} | {:error, error}` + - `Lti_1p3.Platform.authorize_redirect(params, current_user, issuer, claims, opts)` -> `{:ok, payload} | {:error, error}` +- External APIs/webhooks: + - No direct HTTP endpoints; consumer app owns transport. +- Event/message formats: + - Canonical error map: `%{reason: atom(), stage: atom(), msg: String.t(), details: map()}` + +## 6. Runtime Behavior + +- Process model: + - Core validation remains mostly stateless functional modules. + - Key provider remains stateful supervised process. +- Concurrency model: + - Key fetch cache handles concurrent reads; validation functions are pure and process-safe. +- Failure handling/retries: + - JWT key fetch retries bounded and explicit via key provider. + - Launch validation fails fast with stage-specific reason maps. +- Timeouts/circuit breakers: + - Add configurable HTTP timeout for key retrieval; fail closed on timeout. + +## 7. Security and Compliance + +- AuthN/AuthZ impact: + - Enforce issuer/audience/state/nonce/deployment constraints across tool and platform flows. +- Data protection: + - Never expose private key material in public API; sanitize logs. +- Audit/logging requirements: + - Log reason atoms and stage, exclude sensitive token content. + +## 8. Observability and Operations + +- Metrics: + - `launch_validation.duration`, `launch_validation.failure_count`, `key_provider.cache_hit_rate`. +- Logs: + - Structured stage-level logs with correlation id. +- Tracing: + - Telemetry spans around key retrieval and validation stages. +- Alerts/runbooks: + - Alert on elevated invalid-signature failures and key fetch failure spikes. + +## 9. Testing Strategy + +- Unit: + - Validation modules, error mapping, claim normalization, audience/time checks. +- Integration: + - End-to-end tool launch and platform authorization with mocked key endpoints. +- Contract: + - Shared provider behavior conformance tests for all callbacks. +- End-to-end: + - Plug/Phoenix sample integration tests proving framework-agnostic wiring. +- Load/failure: + - High-volume launch validation with stale/rotating keys and nonce duplication attempts. + +## 10. Documentation Strategy + +- ExDoc requirements: + - Every public module must include `@moduledoc`. + - Every public function must include `@doc` and accurate `@spec`. + - Public APIs should include concise examples where practical. +- Supporting docs: + - Maintain core integration, migration, and troubleshooting guides under `docs/`. +- Verification: + - `mix docs` runs in CI for this feature and docs completeness is a release gate. + +## 11. Decisions + +1. API output shape for claims: +Decision: Default to typed structs, with optional raw-claim passthrough. +Implementation impact: core launch/auth APIs should return typed domain structs by default and support an explicit option (for example `raw_claims: true`) to include raw claim maps for advanced integrations. + +2. Provider conformance test packaging: +Decision: No helper macros for third-party adapter libraries. +Implementation impact: provider contract tests remain internal to this repository; external adapters may use documentation-based conformance guidance rather than shipped macro tooling. diff --git a/docs/exec-plans/archive/features/core/manual_qa_report.md b/docs/exec-plans/archive/features/core/manual_qa_report.md new file mode 100644 index 0000000..40e1fb5 --- /dev/null +++ b/docs/exec-plans/archive/features/core/manual_qa_report.md @@ -0,0 +1,29 @@ +# Manual QA Report - Core Feature + +Date: 2026-03-03 + +## Environment + +- Library: `lti_1p3` +- Test command validation: `mix test` (113 tests, 0 failures) + +## Manual acceptance matrix + +- Tool launch happy path: PASS (local verification via API and regression tests) +- Platform authorize redirect happy path: PASS (local verification via API and regression tests) +- Invalid state handling: PASS +- Nonce replay handling: PASS +- Wrong deployment handling: PASS +- Stale token handling: PASS + +## External LMS / external tool sandbox checks + +- Tool launch against external LMS sandbox: NOT EXECUTED in this environment +- Platform authorization against external tool sandbox: NOT EXECUTED in this environment + +Reason: environment/network constraints in the current execution context. + +## Remediation / follow-up + +- Run external interoperability smoke tests in CI or maintainer environment with network access. +- Record external run evidence (request/response traces, outcome screenshots/logs). diff --git a/docs/exec-plans/archive/features/core/plan.md b/docs/exec-plans/archive/features/core/plan.md new file mode 100644 index 0000000..c5f4321 --- /dev/null +++ b/docs/exec-plans/archive/features/core/plan.md @@ -0,0 +1,81 @@ +# Implementation Plan + +## Phase 0 - Alignment and Baseline Audit +### Deliverables +- Approved core PRD/FDD baseline and audited gap list against current modules. + +### Tasks +- [x] Build a core conformance matrix (spec requirement -> current module -> status). +- [x] Enumerate breaking API changes and define final target API signatures. +- [x] Confirm canonical error map schema and reason atom catalog. +- [x] Define provider behavior contract changes and adapter migration expectations. +- [x] Build documentation inventory for all public modules/APIs and required guides under `docs/`. + +### Verification +- [x] Gap matrix reviewed by maintainers. +- [x] Target API and error catalog approved. + +## Phase 1 - API Unification and Contract Refactor +### Deliverables +- Unified top-level `Tool` and `Platform` APIs with consistent tuple contracts. + +### Tasks +- [x] Implement new `Lti_1p3.Tool` core API functions and migrate call sites. +- [x] Implement new `Lti_1p3.Platform` core API functions and migrate call sites. +- [x] Introduce shared core error helpers and replace ad-hoc error map creation. +- [x] Update behavior contracts and reference memory provider implementation. +- [x] Fix naming inconsistencies (including validator path/module naming). + +### Verification +- [x] Core unit tests pass with new API contracts. +- [x] Provider behavior conformance tests pass. + +## Phase 2 - Launch Validation Hardening +### Deliverables +- Fully hardened tool/platform core validation pipelines. + +### Tasks +- [x] Refactor validation into stage-based shared modules (state, registration, jwt, timestamps, deployment, nonce, message). +- [x] Correct and harden issuer/audience/time validations. +- [x] Add launch message dispatcher scaffolding for resource and deep-linking requests. +- [x] Ensure algorithm constraints and kid resolution failures are explicit and tested. +- [x] Add deterministic failure reason coverage for all negative paths. + +### Verification +- [x] Security-focused regression suite passes. +- [x] Stage-level failure reason assertions pass across test matrix. + +## Phase 3 - Observability and Migration Assets +### Deliverables +- Telemetry coverage and migration documentation for integrators. + +### Tasks +- [x] Add telemetry events and structured logs for all validation stages. +- [x] Add docs for new API usage (tool + platform) and migration guide. +- [x] Publish provider adapter migration checklist and sample contract tests. +- [x] Update README and docs pages to new API. +- [x] Ensure `@moduledoc`, `@doc`, and `@spec` are complete for all public core modules/functions. + +### Verification +- [x] Telemetry assertions validated in tests. +- [x] Documentation examples compile in doctests or integration checks. +- [x] `mix docs` completes with full public API coverage and required `docs/` guides present. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual acceptance evidence for core launch interoperability. + +### Tasks +- [ ] Execute manual tool launch flow against at least one external LMS sandbox. +- [ ] Execute manual platform authorization flow against tool sandbox. +- [x] Validate failure handling paths (invalid state, nonce replay, wrong deployment, stale token). +- [x] Capture QA report with pass/fail and remediation items. + +### Verification +- [ ] Manual QA report approved. +- [x] Remaining issues are tracked and triaged. + +## PR Grouping +- PR Group 1: Phases 0-1 +- PR Group 2: Phase 2 +- PR Group 3: Phases 3-4 diff --git a/docs/exec-plans/archive/features/core/prd.md b/docs/exec-plans/archive/features/core/prd.md new file mode 100644 index 0000000..60ae6c2 --- /dev/null +++ b/docs/exec-plans/archive/features/core/prd.md @@ -0,0 +1,87 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: LTI 1.3 Core Completion and API Unification +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-03 +- Status: Proposed + +## 2. Problem Statement +- Current pain: The library has strong baseline support but core LTI 1.3 behavior is incomplete and API ergonomics are inconsistent for tool/platform consumers. +- Why now: AGS/NRPS/Deep Linking completion and certification require a stable, spec-correct core and a cohesive functional API. + +### Current State Analysis +- Launch message validation currently targets only `LtiResourceLinkRequest` (`lib/lti_1p3/tool/message_vaildators/resource_message_validator.ex`). +- Directory/module naming has inconsistencies (`message_vaildators` typo) that leak into maintainability and discoverability. +- Public API is uneven across domains: `Lti_1p3.Platform` exposes only platform instance creation while launch helpers live in nested modules. +- Provider contract return shapes are inconsistent in implementations (for example `get_jwk_by_registration` behavior contract vs memory provider return). +- Utility validation has correctness risk (`validate_audience/2` logic in `lib/lti_1p3/utils.ex`). +- In-memory provider uses `use GenServer` but runtime behavior is `Agent`, indicating architectural drift (`lib/lti_1p3/data_providers/memory_provider.ex`). + +## 3. Goals and Non-Goals +### Goals +- Deliver complete LTI 1.3 core launch correctness for tool and platform flows. +- Provide a simple, coherent functional API with consistent tuple return contracts. +- Standardize validation pipeline outputs and reason atoms for predictable integration. +- Refactor internal architecture for maintainability without framework coupling. +- Treat documentation as a first-class deliverable for every public module and API. + +### Non-Goals +- Maintaining backward compatibility with existing API names or tuple shapes. +- Implementing AGS/NRPS/Deep Linking protocol details beyond core hooks (covered by separate features). +- Building opinionated Phoenix generators or UI workflows. + +## 4. Users and Primary Use Cases +- Personas: Elixir engineers building LTI tools; Elixir engineers building LMS/platforms; maintainers integrating provider adapters. +- Core scenarios: + - Tool app validates OIDC login + launch and consumes normalized claims. + - Platform app validates auth request and emits signed id_token with required claims. + - Integrators manage registrations, deployments, platform instances, and key lifecycle through one consistent API surface. + +## 5. Functional Requirements +1. Expose unified top-level domain APIs for tool and platform core flows. +2. Validate all required OIDC and LTI core claims with explicit reasoned failures. +3. Support both resource link and deep linking launch message validation dispatch (response handling in Deep Linking feature). +4. Normalize registration/deployment/platform instance CRUD contracts and error map shape. +5. Standardize provider behavior return contracts and enforce via tests. +6. Harden JWT validation (kid resolution, signature algorithm constraints, issuer/audience/time/nonce checks). +7. Provide claim normalization helpers that map raw claims into typed structs. +8. Emit telemetry events for core validation success/failure and key retrieval behavior. +9. Document all public modules/functions with `@moduledoc`, `@doc`, and accurate `@spec`, plus runnable examples where practical. +10. Add and maintain integration guides under `docs/` for tool, platform, migration, and troubleshooting workflows. + +## 6. Non-Functional Requirements +- Reliability: Core launch validation failure rate attributable to library defects < 0.1% in certification harness runs. +- Performance: P95 launch validation (excluding remote key fetch latency) < 50ms in local benchmark with warm key cache. +- Security/Compliance: No acceptance of invalid signature/audience/issuer/deployment/nonce; key material handling must never expose private keys. +- Observability: Structured logs + telemetry events for each validation stage and final outcome. +- Documentation: 100% of public API surface is ExDoc-documented and all supporting guides are versioned under `docs/`. + +## 7. Success Metrics +- Product metrics: + - 100% pass for defined core conformance test matrix. + - New API adoption examples for both tool and platform paths in docs. +- Technical metrics: + - >= 95% test coverage for core validation modules. + - Zero inconsistent provider return-shape violations in contract tests. + +## 8. Dependencies and Constraints +- Internal dependencies: Provider behaviors, key provider system, claims modules, launch/auth modules. +- External dependencies: JOSE/Joken correctness, HTTP client behavior for JWK fetch. +- Constraints: Must remain framework-agnostic and support pluggable persistence. + +## 9. Risks and Mitigations +- Risk: API redesign introduces migration friction. +- Mitigation: Publish migration guide and deprecation bridge shim during rollout period. +- Risk: Security regressions during refactor. +- Mitigation: Add negative-path security regression suite and property tests for claim/time validation. +- Risk: Provider ecosystem incompatibility. +- Mitigation: Add behavior conformance tests that external adapters can reuse. + +## 10. Acceptance Criteria +1. Given a valid tool launch request, when `Tool.launch_validate/2` is called, then it returns `{:ok, %Launch{}}` with normalized claims. +2. Given invalid issuer/audience/signature/timestamps/nonce/state/deployment, when validated, then it returns `{:error, %{reason: reason_atom, stage: stage_atom, ...}}` with deterministic reason atoms. +3. Given a valid platform authorization request, when `Platform.authorize_redirect/4` is called, then it returns signed id_token + redirect metadata. +4. Given provider implementations, when contract tests are executed, then all behavior callback shapes and invariants pass. +5. Given telemetry is enabled, when launches are validated, then stage-level and outcome-level events are emitted with correlation metadata. +6. Given `mix docs` runs, when documentation is generated, then all public modules/APIs have complete docs/specs and referenced guides exist under `docs/`. diff --git a/docs/exec-plans/archive/features/deep-linking/fdd.md b/docs/exec-plans/archive/features/deep-linking/fdd.md new file mode 100644 index 0000000..eadee8f --- /dev/null +++ b/docs/exec-plans/archive/features/deep-linking/fdd.md @@ -0,0 +1,120 @@ +# Functional Design Document + +## 1. Design Overview + +- Scope covered: Deep linking request validation, response JWT construction, content item modeling, and platform response validation helpers. +- Assumptions: + - Core launch validation has message-type dispatch extensibility. + - Unified error and telemetry conventions are available. + +## 2. System Context and Boundaries + +- In-scope components: + - Deep linking message validator modules. + - Deep linking request and response domain structs. + - Response JWT builder/signing and validation helpers. +- Out-of-scope components: + - Tool content selection UI workflows. + - Platform post-processing UI logic. + +## 3. Architecture + +- High-level flow: + - Tool receives launch -> message dispatcher selects deep-linking validator -> request normalized. + - Tool selects items -> response builder signs JWT -> POSTs to `deep_link_return_url` via host app. + - Platform validates response JWT -> parses items and correlation data. +- Context/module responsibilities: + - `Lti_1p3.DeepLinking.RequestValidator`: claim-level deep linking request checks. + - `Lti_1p3.DeepLinking.ContentItem`: typed item builders and validators. + - `Lti_1p3.DeepLinking.ResponseBuilder`: JWT claim assembly/signing. + - `Lti_1p3.DeepLinking.ResponseValidator`: platform-side response verification. +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design + +- Schema changes: + - None required by library. +- Data lifecycle: + - Deep linking request/response payloads are ephemeral unless caller persists them. +- Migration/backfill strategy: + - Introduce new structs and helper functions without persistence migration. + +## 5. Interfaces and Contracts + +- Internal APIs: + - `validate_request(launch_claims) -> {:ok, %DeepLinkingRequest{}} | {:error, error}` + - `build_response(request, items, opts) -> {:ok, %{jwt: token, return_url: url}} | {:error, error}` + - `validate_response(jwt, expected) -> {:ok, %DeepLinkingResponse{}} | {:error, error}` + - `content_item(type, attrs) -> {:ok, %ContentItem{}} | {:error, error}` +- External APIs/webhooks: + - Caller app handles HTTP form post to `deep_link_return_url`. +- Event/message formats: + - Response claims include message type, version, content_items, and data. + +## 6. Runtime Behavior + +- Process model: + - Stateless validation/building functions. +- Concurrency model: + - Safe concurrent deep-link request handling. +- Failure handling/retries: + - Build/validate operations do not retry; caller handles transport retries. +- Timeouts/circuit breakers: + - No outbound HTTP in core module path. + +## 7. Security and Compliance + +- AuthN/AuthZ impact: + - Strict JWT signature and issuer/audience checks in response validation. +- Data protection: + - Redact content URLs and user identifiers in logs when configured. +- Audit/logging requirements: + - Log request validation result, response claim count, and validation failures by reason. + +## 8. Observability and Operations + +- Metrics: + - `deep_linking.request.valid_count`, `deep_linking.response.build_count`, `deep_linking.response.invalid_count`. +- Logs: + - Structured logs with request issuer/client_id and response item count. +- Tracing: + - Telemetry spans for request validation and response build. +- Alerts/runbooks: + - Alert on sustained deep linking validation failures. + +## 9. Testing Strategy + +- Unit: + - Claim requirement checks, content item validation, response claim assembly. +- Integration: + - Tool request validation and platform response validation with signed JWTs. +- Contract: + - Message type dispatch includes deep linking request path. +- End-to-end: + - Simulated deep linking handshake from request to response consumption. +- Load/failure: + - Large content item lists and malformed item payload fuzzing. + +## 10. Documentation Strategy + +- ExDoc requirements: + - Document all public deep-linking modules/functions with `@moduledoc`, `@doc`, and `@spec`. + - Add examples for request validation, content item construction, and response token generation. +- Supporting docs: + - Maintain deep-linking request/response integration and troubleshooting guides under `docs/`. +- Verification: + - `mix docs` generation and docs completeness checks are required for merge. + +## 11. Decisions + +1. Content item subtype defaults and flags: +Decision: +- Default enabled: `ltiResourceLink`, `link` +- Feature-flagged: `file`, `html`, `image` +- Always feature-flagged: extended/custom/new types via spec extension mechanisms +Implementation impact: deep-linking content item validation/building must enforce this enablement policy with explicit errors when disabled types are requested. + +2. Response validator `data` behavior: +Decision: allow nil `data` in response when request `data` is absent, and log a warning for potential integration issues. +Implementation impact: response validation should not fail solely for missing `data` when request omitted it, but should emit structured warning logs/telemetry for operator visibility. diff --git a/docs/exec-plans/archive/features/deep-linking/plan.md b/docs/exec-plans/archive/features/deep-linking/plan.md new file mode 100644 index 0000000..92fc4c8 --- /dev/null +++ b/docs/exec-plans/archive/features/deep-linking/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - Deep Linking Audit and Contracts +### Deliverables +- Deep linking requirement matrix and finalized API contracts. + +### Tasks +- [ ] Map deep linking spec requirements to existing modules and identify gaps. +- [ ] Define deep linking request/response structs and content item model set. +- [ ] Define validation rules and reason atom catalog for deep linking failures. +- [ ] Build deep-linking documentation inventory for public APIs and required guides under `docs/`. + +### Verification +- [ ] Requirement matrix approved. +- [ ] API contracts approved. + +## Phase 1 - Request Validation and Message Dispatch +### Deliverables +- `LtiDeepLinkingRequest` validation integrated into launch pipeline. + +### Tasks +- [ ] Implement deep linking request validator module. +- [ ] Integrate validator into message-type dispatch pipeline. +- [ ] Parse deep linking settings claim into typed struct. +- [ ] Add unit tests for required/optional claim validations. + +### Verification +- [ ] Dispatch tests pass for resource and deep linking message types. +- [ ] Negative-path claim validation tests pass. + +## Phase 2 - Response Builder and Content Items +### Deliverables +- Deep linking response JWT builder with typed content items. + +### Tasks +- [ ] Implement content item builders/validators. +- [ ] Implement response claim assembly with message type/version/content_items/data. +- [ ] Implement JWT signing helper using configured active key. +- [ ] Add compatibility options for restricted LMS item capabilities. + +### Verification +- [ ] Integration tests pass for response token validity and claim correctness. +- [ ] Unsupported item behavior tests pass. + +## Phase 3 - Platform Response Validation and Docs +### Deliverables +- Platform-side deep linking response validation and updated docs. + +### Tasks +- [ ] Implement deep linking response validator with issuer/audience/data checks. +- [ ] Add telemetry events and structured logs. +- [ ] Add README/docs deep linking request/response examples. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage for all public deep-linking modules/functions. + +### Verification +- [ ] Response validation integration tests pass. +- [ ] Documentation examples validated. +- [ ] `mix docs` completes and deep-linking guides under `docs/` are complete. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual deep linking interoperability report. + +### Tasks +- [ ] Validate deep linking request handling against at least 2 LMS sandboxes. +- [ ] Validate response submission and content item ingestion. +- [ ] Validate negative paths (missing claims, invalid signatures, unsupported items). +- [ ] Record QA report and remediation backlog. + +### Verification +- [ ] Manual QA report approved. +- [ ] Critical issues triaged before release. diff --git a/docs/exec-plans/archive/features/deep-linking/prd.md b/docs/exec-plans/archive/features/deep-linking/prd.md new file mode 100644 index 0000000..012765c --- /dev/null +++ b/docs/exec-plans/archive/features/deep-linking/prd.md @@ -0,0 +1,83 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: LTI Deep Linking 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-03 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Deep Linking claims exist but full request/response flow and content-item response handling are not complete. +- Why now: Deep Linking is required for full LTI service completeness and certification readiness. + +### Current State Analysis +- `Lti_1p3.Claims.DeepLinkingSettings` exists, but launch validation currently only validates resource link message type. +- No complete tool-side deep linking request validator is present in message validator dispatch. +- No first-class API exists to build/sign deep linking response JWTs with content items. +- No platform-side helper exists to validate/consume deep linking responses. +- Current tests do not provide deep linking request/response end-to-end coverage. + +## 3. Goals and Non-Goals +### Goals +- Implement complete deep linking request validation for tool launches. +- Implement deep linking response builder with content item modeling and JWT signing. +- Provide platform-side deep linking response validation/consumption helpers. +- Align deep linking API with unified library functional conventions. +- Make deep-linking documentation complete in ExDoc and dedicated guides under `docs/`. + +### Non-Goals +- Building authoring UIs for selecting deep link content. +- Persisting content-item catalogs in the library. + +## 4. Users and Primary Use Cases +- Personas: Tool developers offering content selection; platform developers consuming deep linking responses. +- Core scenarios: + - Tool validates incoming `LtiDeepLinkingRequest` and reads deep linking settings. + - Tool builds a signed `LtiDeepLinkingResponse` with selected content items. + - Platform validates deep linking response JWT and extracts content items/data correlation. + +## 5. Functional Requirements +1. Add message validator for `LtiDeepLinkingRequest` with required claim checks. +2. Parse deep linking settings claim into typed struct with defaults and validation. +3. Define typed content item models (link, ltiResourceLink, html, image, file where applicable). +4. Implement response JWT builder with message type/version/content_items/data claims. +5. Support optional AGS and custom claim embedding in deep linking items where valid. +6. Implement platform-side deep linking response validation helpers. +7. Enforce nonce/state/data correlation and issuer/audience validation for responses. +8. Provide clear errors for unsupported content-item types and invalid payloads. +9. Document all deep-linking public modules/functions with `@moduledoc`, `@doc`, and `@spec`. +10. Add/update deep-linking guides under `docs/` (request handling, response building, platform validation, interoperability notes). + +## 6. Non-Functional Requirements +- Reliability: Response builder must produce deterministic payloads for same inputs. +- Performance: Deep linking response build/validation should be CPU-bound and complete < 20ms local. +- Security/Compliance: JWT validation and signing must follow same strict core checks. +- Observability: Emit telemetry for request validation, response build, and response validation outcomes. +- Documentation: 100% deep-linking public API ExDoc coverage and complete supporting guides in `docs/`. + +## 7. Success Metrics +- Product metrics: + - Pass deep linking conformance scenarios in certification tests. + - Successful deep linking exchange with at least 2 LMS sandboxes. +- Technical metrics: + - >= 90% deep linking module coverage. + - 100% required claim validation cases covered. + +## 8. Dependencies and Constraints +- Internal dependencies: Core launch/message validation refactor; claims system; key signing utilities. +- External dependencies: LMS behavior around accepted content item types. +- Constraints: Keep framework-agnostic and avoid UI assumptions. + +## 9. Risks and Mitigations +- Risk: LMS-specific limitations on content item types break interoperability. +- Mitigation: Provide capability negotiation and configurable fallback item shaping. +- Risk: Data correlation mismatches across request/response. +- Mitigation: Enforce explicit `data` passthrough tests and validation constraints. + +## 10. Acceptance Criteria +1. Given an incoming deep linking launch, when validated, then it returns typed deep linking request data including settings claim. +2. Given selected content items, when response builder is called, then it returns signed JWT with valid deep linking response claims. +3. Given malformed content items or missing required claims, when building/validating, then structured errors are returned. +4. Given platform receives deep linking response, when validated, then content items are extracted with correlation `data` preserved. +5. Given unsupported item type for target LMS, when compatibility policy is enabled, then fallback or explicit error behavior is deterministic. +6. Given `mix docs` runs, when deep-linking docs are generated, then all deep-linking public APIs and supporting guides are complete and current. diff --git a/docs/exec-plans/archive/features/nrps/fdd.md b/docs/exec-plans/archive/features/nrps/fdd.md new file mode 100644 index 0000000..9f729ca --- /dev/null +++ b/docs/exec-plans/archive/features/nrps/fdd.md @@ -0,0 +1,104 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: NRPS claim parsing, scope policy, paginated memberships client, typed membership modeling. +- Assumptions: + - Core error contract and telemetry conventions are available. + - Caller supplies valid access token with relevant scope. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Tool.Services.NRPS` public API redesign. + - `Membership` and `MembershipPage` models. + - NRPS scope and claim validators. +- Out-of-scope components: + - Roster persistence or synchronization jobs. + - Platform-side NRPS server implementation. + +## 3. Architecture +- High-level flow: + - Parse launch NRPS claim -> validate scope -> request first page -> follow `Link: rel=next` until complete. +- Context/module responsibilities: + - `Lti_1p3.Services.NRPS`: entry point operations. + - `Lti_1p3.Services.NRPS.ScopePolicy`: scope enforcement. + - `Lti_1p3.Services.NRPS.Client`: HTTP requests + pagination headers. + - `Lti_1p3.Services.NRPS.Parser`: membership decoding and normalization. +- Supervision tree impact: + - No new OTP workers required. + +## 4. Data Design +- Schema changes: + - None in core library. +- Data lifecycle: + - Membership data exists in-memory for request lifecycle unless caller persists externally. +- Migration/backfill strategy: + - Provide migration notes for renamed helpers and corrected required scopes. + +## 5. Interfaces and Contracts +- Internal APIs: + - `from_launch_claim(claim_map) -> {:ok, %NrpsEndpoint{}} | {:error, error}` + - `list_memberships(endpoint, token, opts) -> {:ok, %MembershipPage{}} | {:error, error}` + - `stream_memberships(endpoint, token, opts) -> Enumerable.t()` + - `fetch_all_memberships(endpoint, token, opts) -> {:ok, [%Membership{}]} | {:error, error}` +- External APIs/webhooks: + - NRPS `context_memberships_url` endpoint. +- Event/message formats: + - Error map: `%{reason:, operation:, http_status:, retryable:, msg:}`. + +## 6. Runtime Behavior +- Process model: + - Stateless function calls and enumerables. +- Concurrency model: + - Supports parallel roster requests across different contexts. +- Failure handling/retries: + - Bounded retry for transient failures; no silent partial success. +- Timeouts/circuit breakers: + - Configurable request timeout and max pages safety guard. + +## 7. Security and Compliance +- AuthN/AuthZ impact: + - Validate `https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly` before call. +- Data protection: + - Optional redaction of email/name/picture fields in logs. +- Audit/logging requirements: + - Log request host, page index, result size, and error categories. + +## 8. Observability and Operations +- Metrics: + - `nrps.request.count`, `nrps.page.count`, `nrps.membership.count`, `nrps.error.count`. +- Logs: + - Structured page-level and aggregate fetch logs. +- Tracing: + - Telemetry spans around each page retrieval. +- Alerts/runbooks: + - Alert when page traversal failures exceed threshold. + +## 9. Testing Strategy +- Unit: + - Scope validation, link header parser, role normalization helpers. +- Integration: + - Multi-page mocked NRPS responses, filter behaviors, retries. +- Contract: + - Required scope constants and claim parsing invariants. +- End-to-end: + - Launch -> token -> roster retrieval flow. +- Load/failure: + - Large roster paging and intermittent timeout simulation. + +## 10. Documentation Strategy +- ExDoc requirements: + - Document all public NRPS modules/functions with `@moduledoc`, `@doc`, and `@spec`. + - Include examples for page fetch, streaming, and full-roster retrieval. +- Supporting docs: + - Maintain NRPS setup, scope, pagination, and troubleshooting guides under `docs/`. +- Verification: + - `mix docs` generation and docs completeness checks are required for merge. + +## 11. Decisions +1. Deduplication strategy for repeated members across pages: +Decision: No built-in deduplication strategy. +Implementation impact: NRPS pagination APIs will preserve source ordering/content as returned by the LMS; callers that need deduplication can apply it explicitly in their application layer. + +2. Role helper conversion output model: +Decision: Use existing role structs with NRPS-specific normalization. +Implementation impact: role conversion helpers should normalize NRPS role claims into the current shared role structs rather than introducing dedicated NRPS enum types. diff --git a/docs/exec-plans/archive/features/nrps/plan.md b/docs/exec-plans/archive/features/nrps/plan.md new file mode 100644 index 0000000..0613c85 --- /dev/null +++ b/docs/exec-plans/archive/features/nrps/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - NRPS Audit and Target API +### Deliverables +- NRPS requirement matrix and corrected scope policy specification. + +### Tasks +- [ ] Map NRPS spec requirements to current module/test coverage. +- [ ] Correct required scope definitions and document compatibility assumptions. +- [ ] Finalize target API signatures for page, stream, and full-fetch modes. +- [ ] Build NRPS documentation inventory for public APIs and required guides under `docs/`. + +### Verification +- [ ] Matrix and scope policy approved. +- [ ] API signatures approved. + +## Phase 1 - NRPS Contracts and Parsing +### Deliverables +- Typed NRPS endpoint and membership models with structured errors. + +### Tasks +- [ ] Implement endpoint claim parser and service version validation. +- [ ] Implement membership model and role normalization helpers. +- [ ] Implement structured error mapper for NRPS operations. +- [ ] Update `required_scopes/0` and scope validation behavior. + +### Verification +- [ ] Unit tests for parsing/scope/normalization pass. +- [ ] Scope regression tests cover incorrect legacy behavior. + +## Phase 2 - Pagination and Retrieval Modes +### Deliverables +- Full pagination support and retrieval APIs. + +### Tasks +- [ ] Implement page retrieval with `Link` header parsing. +- [ ] Implement lazy stream API over page traversal. +- [ ] Implement eager all-members fetch with max-page safeguards. +- [ ] Add support for optional filters and limit overrides. + +### Verification +- [ ] Integration tests pass for multi-page and filtered retrieval. +- [ ] Memory usage and max-page guard behavior validated. + +## Phase 3 - Hardening and Docs +### Deliverables +- Production-ready NRPS telemetry, retries, and docs. + +### Tasks +- [ ] Add telemetry events and structured logging. +- [ ] Implement bounded retry behavior for transient failures. +- [ ] Update README/docs with NRPS integration examples. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage for all public NRPS modules/functions. + +### Verification +- [ ] Telemetry and retry behavior tests pass. +- [ ] Documentation examples validated. +- [ ] `mix docs` completes and NRPS guides under `docs/` are complete. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual NRPS interoperability report. + +### Tasks +- [ ] Validate roster retrieval against at least 2 LMS sandboxes. +- [ ] Validate multi-page traversal and filter behavior. +- [ ] Validate negative paths (scope denial, token expiry, bad JSON). +- [ ] Capture QA report and remediation items. + +### Verification +- [ ] Manual QA report approved. +- [ ] Critical defects triaged before merge. diff --git a/docs/exec-plans/archive/features/nrps/prd.md b/docs/exec-plans/archive/features/nrps/prd.md new file mode 100644 index 0000000..9a3bdce --- /dev/null +++ b/docs/exec-plans/archive/features/nrps/prd.md @@ -0,0 +1,83 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: LTI NRPS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-03 +- Status: Proposed + +## 2. Problem Statement +- Current pain: NRPS support is currently minimal and does not provide complete pagination, filtering, role normalization, or robust error semantics. +- Why now: Full LTI service coverage and certification require spec-compliant roster retrieval behavior. + +### Current State Analysis +- `Lti_1p3.Tool.Services.NRPS` currently fetches a single page using a hardcoded `limit` query append. +- `required_scopes/0` returns a claim key (`context_memberships_url`) rather than NRPS scope URL, which is a correctness issue. +- Service version compatibility and optional query parameters are not explicitly supported. +- Error handling is string-based and does not expose operation/status/retryability metadata. +- Tests cover only basic access and header shape, not full protocol semantics. + +## 3. Goals and Non-Goals +### Goals +- Implement complete NRPS 2.0 tool-side client support with pagination and filtering. +- Enforce proper NRPS scope validation and claim parsing. +- Normalize memberships into typed structures with role helper utilities. +- Align NRPS API and errors with unified library conventions. +- Require full ExDoc coverage and NRPS operational guides in `docs/`. + +### Non-Goals +- Persisting roster snapshots by default. +- Implementing institution-specific enrollment reconciliation logic. + +## 4. Users and Primary Use Cases +- Personas: Tool developers synchronizing class rosters; platform integrators validating names/roles launch claims. +- Core scenarios: + - Tool checks NRPS availability and scopes from launch claim. + - Tool fetches full memberships list across all pages. + - Tool filters memberships by role/status and maps standardized role helpers. + +## 5. Functional Requirements +1. Parse NRPS claim into typed endpoint config including service version and URL. +2. Validate required NRPS scope URL(s) before outbound requests. +3. Retrieve memberships with support for paging via `Link` headers and query parameters. +4. Support optional role/limit/resourceLink filters where LMS supports them. +5. Normalize membership payload into typed structs with role helper conversion. +6. Return structured errors with reason atoms and retryability hints. +7. Provide convenience API for eager full-roster fetch and lazy page iteration. +8. Emit telemetry events for NRPS requests and pagination behavior. +9. Document all NRPS public modules/functions with `@moduledoc`, `@doc`, and `@spec`. +10. Add/update NRPS guides (setup, scope handling, pagination, and troubleshooting) under `docs/`. + +## 6. Non-Functional Requirements +- Reliability: Full-roster retrieval should tolerate intermittent failures with controlled retry policy. +- Performance: Iterative pagination should avoid unbounded memory usage. +- Security/Compliance: Enforce scope validation and redact PII in logs by default. +- Observability: Track request count, page count, and failure reasons. +- Documentation: 100% NRPS public API ExDoc coverage and complete supporting docs in `docs/`. + +## 7. Success Metrics +- Product metrics: + - NRPS conformance scenarios pass in certification plan. + - Successful roster retrieval across at least 2 LMS sandboxes. +- Technical metrics: + - >= 90% NRPS module test coverage. + - 100% validation tests for required scope URL correctness. + +## 8. Dependencies and Constraints +- Internal dependencies: Core API refactor, shared HTTP utilities, role claim helpers. +- External dependencies: LMS NRPS pagination and filter behavior variance. +- Constraints: Must stay framework-agnostic and avoid mandatory persistence. + +## 9. Risks and Mitigations +- Risk: LMS pagination implementations differ from spec. +- Mitigation: Implement tolerant parser with compatibility strategy toggles. +- Risk: Roster fetch can become large and memory-heavy. +- Mitigation: Provide streaming/page iterator API and caller-controlled accumulation. + +## 10. Acceptance Criteria +1. Given valid NRPS claim and scope, when memberships are fetched, then API returns typed memberships and follows pagination links until completion. +2. Given invalid/missing NRPS scope, when request is attempted, then API returns `{:error, %{reason: :insufficient_scope, ...}}`. +3. Given multi-page responses, when using eager full fetch, then all pages are included exactly once. +4. Given filters are provided, when LMS supports them, then filtered results are returned. +5. Given NRPS failures (4xx/5xx/timeout), when operations fail, then structured error maps with retryability are returned. +6. Given `mix docs` runs, when NRPS docs are generated, then all NRPS public APIs are documented and referenced guides exist under `docs/`. diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md new file mode 100644 index 0000000..5deef12 --- /dev/null +++ b/docs/exec-plans/tech-debt-tracker.md @@ -0,0 +1,7 @@ +# Tech Debt Tracker + +Track debt items here when they are too small for standalone feature work but still worth scheduling. + +- Record the impacted module or flow. +- Note the operational or maintenance cost. +- Link follow-up work in `docs/exec-plans/current/` when a debt item becomes active. diff --git a/docs/features/platform-ags/fdd.md b/docs/features/platform-ags/fdd.md new file mode 100644 index 0000000..193472a --- /dev/null +++ b/docs/features/platform-ags/fdd.md @@ -0,0 +1,72 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Platform AGS authorization, operation orchestration, persistence integration, observability. +- Assumptions: + - Platform token issuance and scope claims are available. + - Tool AGS is implemented first and may provide extracted reusable helper modules. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Platform.Services.AGS` public APIs. + - Authorization and error normalization modules. + - Persistence integration boundary for line item/score/result storage. + - Adoption of extracted reusable helper modules when duplication exists. +- Out-of-scope components: + - Tool AGS HTTP client behavior. + - Gradebook UI workflows. + +## 3. Architecture +- High-level flow: + - Receive AGS request context -> authorize operation -> execute storage/query operation -> normalize response. +- Context/module responsibilities: + - `Lti_1p3.Platform.Services.AGS` + - `Lti_1p3.Platform.Services.AGS.ScopePolicy` + - `Lti_1p3.Platform.Services.AGS.Errors` + - `Lti_1p3.Platform.Services.AGS.LineItems` + - `Lti_1p3.Platform.Services.AGS.Scores` + - `Lti_1p3.Platform.Services.AGS.Results` + - Reused helpers extracted from tool implementation (for example: pagination link utilities, shared response mapping helpers). +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none in library core. +- Data lifecycle: platform operations work against host application data adapters. +- Migration/backfill strategy: provide migration notes when module/function names change due to utility extraction. + +## 5. Interfaces and Contracts +- `authorize_operation(claims, operation, context) -> :ok | {:error, error}` +- `list_line_items(ctx, opts) -> {:ok, %Page{}} | {:error, error}` +- `create_line_item(ctx, attrs) -> {:ok, %LineItem{}} | {:error, error}` +- `update_line_item(ctx, id_or_url, attrs) -> {:ok, %LineItem{}} | {:error, error}` +- `delete_line_item(ctx, id_or_url) -> :ok | {:error, error}` +- `post_score(ctx, id_or_url, %Score{}) -> :ok | {:error, error}` +- `list_results(ctx, id_or_url, opts) -> {:ok, %Page{}} | {:error, error}` + +## 6. Runtime Behavior +- Stateless orchestration with concurrent-safe request handling. +- No implicit write retries. +- Optional timeout configuration for adapter interactions. + +## 7. Security and Compliance +- Scope/context/deployment checks before operation execution. +- Sensitive value redaction in structured logs. +- Stable reason atoms for authorization and validation paths. + +## 8. Observability and Operations +- Metrics: + - `platform_ags.request.count` + - `platform_ags.denied.count` + - `platform_ags.error.count` +- Logs: operation, deployment/context, status class, reason. +- Tracing: telemetry spans around authorization and operation execution. + +## 9. Testing Strategy +- Unit: scope policy, error mapping, response normalization. +- Integration: line item/score/result success and denial flows. +- End-to-end: tool-to-platform AGS interoperability scenarios. +- Load/failure: concurrent requests and timeout handling. + +## 10. Open Questions +- Which extracted tool utilities are stable enough to promote before platform AGS phase 2 starts? diff --git a/docs/features/platform-ags/plan.md b/docs/features/platform-ags/plan.md new file mode 100644 index 0000000..3bc3f87 --- /dev/null +++ b/docs/features/platform-ags/plan.md @@ -0,0 +1,71 @@ +# Implementation Plan + +## Phase 0 - AGS Platform Baseline +### Deliverables +- AGS platform requirement matrix and finalized API surface. + +### Tasks +- [ ] Define operation/scope/context authorization matrix. +- [ ] Review tool AGS modules for reusable helper candidates. +- [ ] Finalize platform typed models and error reason catalog. +- [ ] Finalize platform AGS docs inventory. + +### Verification +- [ ] Requirement matrix approved. +- [ ] Reusable helper candidate list approved. + +## Phase 1 - Authorization and Core Models +### Deliverables +- Authorization pipeline and model definitions. + +### Tasks +- [ ] Implement scope/context/deployment policy helpers. +- [ ] Implement core platform AGS structs. +- [ ] Normalize error mapping for all public operations. +- [ ] Add unit tests for authorization and error paths. + +### Verification +- [ ] Authorization and error tests pass. +- [ ] Model serialization/validation tests pass. + +## Phase 2 - Operations and Reuse +### Deliverables +- Complete platform line item/score/result operations with minimized duplication. + +### Tasks +- [ ] Implement line item list/create/read/update/delete operations. +- [ ] Implement score ingestion and result retrieval operations. +- [ ] Extract duplicated logic from tool AGS into reusable modules and replace duplicate platform logic with those modules. +- [ ] Add integration tests for success and denial paths. + +### Verification +- [ ] Integration tests pass for all operations. +- [ ] Duplicated helper implementations are replaced by reusable modules where applicable. + +## Phase 3 - Hardening and Documentation +### Deliverables +- Production-ready observability and docs. + +### Tasks +- [ ] Add telemetry events and structured logs. +- [ ] Validate reusable module usage across tool/platform AGS paths. +- [ ] Update docs with platform AGS examples and integration guidance. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage. + +### Verification +- [ ] Telemetry assertions pass. +- [ ] `mix docs` completes with current platform AGS guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual AGS platform interoperability report. + +### Tasks +- [ ] Validate platform AGS behavior with at least 2 reference tools. +- [ ] Validate negative paths (scope denial, bad context, malformed payload). +- [ ] Validate reusable module behavior in tool and platform AGS flows. +- [ ] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/platform-ags/prd.md b/docs/features/platform-ags/prd.md new file mode 100644 index 0000000..61560d2 --- /dev/null +++ b/docs/features/platform-ags/prd.md @@ -0,0 +1,74 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Platform AGS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Platform-side AGS service behavior needs a complete, spec-aligned implementation plan. +- Why now: Certification and production interoperability require robust platform AGS authorization and operations. + +### Current State Analysis +- Platform AGS endpoint behavior is not fully implemented. +- Authorization and response semantics need complete test coverage. +- Tool AGS implementation will land first and should inform shared utility extraction. + +## 3. Goals and Non-Goals +### Goals +- Implement platform AGS service behavior for line items, scores, and results. +- Enforce deployment/context/scope authorization with stable reason atoms. +- Provide typed models and normalized error/HTTP mapping guidance. +- Reuse extracted modules for duplicated logic that already exists in tool AGS implementation. +- Publish complete platform AGS documentation. + +### Non-Goals +- Building a full LMS gradebook product. +- Institution-specific grading policies. + +## 4. Users and Primary Use Cases +- Personas: Platform developers exposing AGS endpoints to tools. +- Core scenarios: + - Authorize AGS operations from tool access tokens. + - Serve line item CRUD responses. + - Accept scores and return results. + +## 5. Functional Requirements +1. Implement platform AGS domain models for line item, score event, and result view. +2. Implement authorization policy for scope, deployment, and context checks. +3. Implement service operations for line items, scores, and results. +4. Return structured errors with reason atoms and HTTP mapping. +5. Emit telemetry/logging for operation outcomes and denials. +6. During implementation, extract duplicated helper logic from tool AGS modules into reusable modules and consume them in platform AGS. +7. Document all public platform AGS APIs and integration guidance. + +## 6. Non-Functional Requirements +- Reliability: deterministic behavior under concurrent requests. +- Performance: bounded pagination and query cost. +- Security/Compliance: strict authorization checks and audit-friendly logs. +- Documentation: complete ExDoc and implementation guides. + +## 7. Success Metrics +- Platform AGS conformance scenarios pass certification matrix. +- Interop validated with at least 2 reference tools. +- >= 90% coverage for platform AGS modules. +- Duplicated cross-role logic is reduced through reusable extracted modules. + +## 8. Dependencies and Constraints +- Internal dependencies: platform launch/token modules, tool AGS extracted reusable helpers. +- External dependencies: host application persistence integration quality. +- Constraints: framework-agnostic APIs and stable return tuple shapes. + +## 9. Risks and Mitigations +- Risk: duplicated utility logic diverges between roles. +- Mitigation: extract reusable modules from tool implementation before parallel platform rewrites. +- Risk: authorization gaps create security issues. +- Mitigation: centralized policy helpers and exhaustive denial-path tests. + +## 10. Acceptance Criteria +1. Given authorized AGS scopes/context, platform operations succeed with spec-compliant payloads. +2. Given insufficient scope or context mismatch, platform returns explicit authorization errors. +3. Given duplicated helper logic already solved in tool AGS, platform uses extracted reusable modules. +4. Given failures, platform returns normalized reasons and telemetry metadata. +5. Given docs generation, platform AGS API and guide docs are complete. diff --git a/docs/features/platform-deep-linking/fdd.md b/docs/features/platform-deep-linking/fdd.md new file mode 100644 index 0000000..16bf45f --- /dev/null +++ b/docs/features/platform-deep-linking/fdd.md @@ -0,0 +1,68 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Platform deep-link request building, response validation, content item parsing, observability. +- Assumptions: + - Platform launch/session context includes expected correlation values. + - Tool deep-linking implementation can provide extracted reusable helper modules. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Platform.DeepLinking.RequestBuilder` + - `Lti_1p3.Platform.DeepLinking.ResponseValidator` + - `Lti_1p3.Platform.DeepLinking.ContentItemParser` + - `Lti_1p3.Platform.DeepLinking.Errors` + - Adoption of extracted reusable helper modules where duplication exists. +- Out-of-scope components: + - Tool response generation behavior. + - Host app persistence/UI workflows. + +## 3. Architecture +- High-level flow: + - Build request claims/context -> receive response JWT -> verify signature and claims -> validate correlation -> parse items -> return normalized response. +- Context/module responsibilities: + - `Lti_1p3.Platform.DeepLinking.RequestBuilder` + - `Lti_1p3.Platform.DeepLinking.ResponseValidator` + - `Lti_1p3.Platform.DeepLinking.ContentItemParser` + - `Lti_1p3.Platform.DeepLinking.CompatibilityPolicy` + - `Lti_1p3.Platform.DeepLinking.Errors` + - Reused helpers extracted from tool implementation (for example: claim correlation and content item normalization helpers). +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none. +- Data lifecycle: request/response/item structs are request-scoped unless host app persists. +- Migration/backfill strategy: additive API and helper extraction changes with migration notes. + +## 5. Interfaces and Contracts +- `build_request(context, opts) -> {:ok, %DeepLinkingPlatformRequest{}} | {:error, error}` +- `validate_response(jwt, expected) -> {:ok, %DeepLinkingPlatformResponse{}} | {:error, error}` +- `parse_content_items(claims, opts) -> {:ok, [%ContentItem{}]} | {:error, error}` + +## 6. Runtime Behavior +- Stateless validation/build operations. +- Concurrent-safe for many simultaneous launches. +- No long-lived processes or retries in core flow. + +## 7. Security and Compliance +- Strict signature verification and issuer/audience checks. +- Enforce nonce/state/data correlation rules. +- Redact sensitive values from logs and telemetry metadata. + +## 8. Observability and Operations +- Metrics: + - `platform_deep_linking.request.build_count` + - `platform_deep_linking.response.valid_count` + - `platform_deep_linking.response.invalid_count` +- Logs: issuer/client_id, correlation result, item count, reason. +- Tracing: telemetry spans around request build and response validation. + +## 9. Testing Strategy +- Unit: claim validation, correlation checks, item normalization, error mapping. +- Integration: signed response token validation paths. +- End-to-end: request creation through response consumption. +- Load/failure: high-volume response validation and malformed payload handling. + +## 10. Open Questions +- Which extracted tool deep-linking helpers should be adopted before platform phase 2 to maximize reuse with minimal churn? diff --git a/docs/features/platform-deep-linking/plan.md b/docs/features/platform-deep-linking/plan.md new file mode 100644 index 0000000..c6db20f --- /dev/null +++ b/docs/features/platform-deep-linking/plan.md @@ -0,0 +1,71 @@ +# Implementation Plan + +## Phase 0 - Deep Linking Platform Baseline +### Deliverables +- Platform deep-linking requirement matrix and target API signatures. + +### Tasks +- [ ] Map platform deep-linking responsibilities and current gaps. +- [ ] Review tool deep-linking modules for reusable helper candidates. +- [ ] Finalize request builder/response validator/content parser contracts. +- [ ] Finalize platform deep-linking docs inventory. + +### Verification +- [ ] Requirement matrix approved. +- [ ] Reusable helper candidate list approved. + +## Phase 1 - Request and Validation Foundations +### Deliverables +- Request builder, correlation validator foundations, and typed response models. + +### Tasks +- [ ] Implement request builder with required claim validation. +- [ ] Implement response claim validation and structured error mapping. +- [ ] Implement typed response/content item models. +- [ ] Add unit tests for claim and correlation failure paths. + +### Verification +- [ ] Unit tests pass for request/validation models. +- [ ] Structured error mapping tests pass. + +## Phase 2 - Response Parsing and Reuse +### Deliverables +- Complete response parsing and minimized duplicate logic. + +### Tasks +- [ ] Implement signature verification and issuer/audience checks. +- [ ] Implement content item parsing and compatibility behavior. +- [ ] Extract duplicated helpers from tool deep-linking into reusable modules and adopt those modules in platform deep-linking. +- [ ] Add integration tests for valid/invalid JWT flows. + +### Verification +- [ ] Integration tests pass for signature/claim/correlation paths. +- [ ] Duplicated helper implementations are replaced by reusable modules where applicable. + +## Phase 3 - Hardening and Documentation +### Deliverables +- Production-ready observability and docs. + +### Tasks +- [ ] Add telemetry events and structured logs. +- [ ] Validate reusable module usage across tool/platform deep-linking flows. +- [ ] Update docs with platform deep-linking examples and integration guidance. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage. + +### Verification +- [ ] Telemetry assertions pass. +- [ ] `mix docs` completes with current platform deep-linking guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual platform deep-linking interoperability report. + +### Tasks +- [ ] Validate interoperability with at least 2 reference tools. +- [ ] Validate unsupported subtype handling behavior. +- [ ] Validate negative paths (signature, claim, correlation failures). +- [ ] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/platform-deep-linking/prd.md b/docs/features/platform-deep-linking/prd.md new file mode 100644 index 0000000..ea824a9 --- /dev/null +++ b/docs/features/platform-deep-linking/prd.md @@ -0,0 +1,75 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Platform Deep Linking 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Platform deep-linking behavior needs complete request creation and response validation coverage. +- Why now: Certification and interoperability require robust platform deep-linking flows. + +### Current State Analysis +- Platform request creation and response verification need broader implementation coverage. +- Correlation and item parsing behavior need complete test coverage. +- Tool deep-linking will be implemented first and should inform reusable module extraction where duplication appears. + +## 3. Goals and Non-Goals +### Goals +- Implement platform APIs for deep-link request construction and response validation. +- Enforce signature, issuer/audience, nonce/state/data correlation checks. +- Normalize returned content items into typed structures. +- Reuse modules extracted from tool deep-linking where equivalent logic exists. +- Publish complete platform deep-linking documentation. + +### Non-Goals +- Platform authoring UI. +- Default persistence of selected content items. + +## 4. Users and Primary Use Cases +- Personas: Platform developers launching deep-linking flows and consuming returned content items. +- Core scenarios: + - Build deep-linking launch request claims. + - Validate tool response JWT. + - Parse and normalize content items for host app processing. + +## 5. Functional Requirements +1. Implement helper API for platform deep-link request claim construction. +2. Validate deep-link response JWT signatures and required claims. +3. Enforce nonce/state/data correlation checks. +4. Parse and normalize returned content items. +5. Return structured errors for signature, claim, and correlation failures. +6. Emit telemetry/logging for request and response processing. +7. Extract duplicated helpers from tool deep-linking into reusable modules and consume them in platform deep-linking. +8. Document all public platform deep-linking APIs. + +## 6. Non-Functional Requirements +- Reliability: deterministic validation outcomes. +- Performance: bounded local parsing/validation cost. +- Security/Compliance: strict JWT verification and correlation checks. +- Documentation: complete ExDoc and integration guides. + +## 7. Success Metrics +- Platform deep-linking conformance scenarios pass certification matrix. +- Interoperability verified with at least 2 reference tools. +- >= 90% coverage for platform deep-linking modules. +- Cross-role duplicate logic is reduced through extracted reusable modules. + +## 8. Dependencies and Constraints +- Internal dependencies: platform launch pipeline, key retrieval/JWT verification, tool deep-linking extracted reusable helpers. +- External dependencies: tool behavior variance in optional deep-linking claims. +- Constraints: framework-agnostic API design and stable tagged-tuple shapes. + +## 9. Risks and Mitigations +- Risk: duplicated claim/correlation logic diverges between roles. +- Mitigation: extract and reuse concrete helpers from tool implementation where stable. +- Risk: overly strict correlation creates false rejects. +- Mitigation: explicit validation contracts and exhaustive negative-path tests. + +## 10. Acceptance Criteria +1. Given request input, helper APIs produce valid platform deep-link request claims. +2. Given valid response JWT, validator returns typed response and parsed items. +3. Given invalid signature/claims/correlation, validator returns structured errors. +4. Given duplicate logic already solved in tool deep-linking, platform uses extracted reusable modules. +5. Given docs generation, platform deep-linking API and guide docs are complete. diff --git a/docs/features/platform-nrps/fdd.md b/docs/features/platform-nrps/fdd.md new file mode 100644 index 0000000..f46fe45 --- /dev/null +++ b/docs/features/platform-nrps/fdd.md @@ -0,0 +1,69 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Platform NRPS authorization, membership retrieval orchestration, pagination/filter handling, observability. +- Assumptions: + - Platform token and scope validation is available. + - Tool NRPS implementation can provide extracted reusable helper modules. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Platform.Services.NRPS` public APIs. + - Scope policy and filter validation. + - Membership/page response normalization. + - Adoption of extracted reusable modules for duplicate logic. +- Out-of-scope components: + - Tool-side NRPS client behavior. + - SIS synchronization pipelines. + +## 3. Architecture +- High-level flow: + - Receive membership request context -> authorize scope/context -> validate filters/page params -> retrieve memberships -> normalize response. +- Context/module responsibilities: + - `Lti_1p3.Platform.Services.NRPS` + - `Lti_1p3.Platform.Services.NRPS.ScopePolicy` + - `Lti_1p3.Platform.Services.NRPS.Filters` + - `Lti_1p3.Platform.Services.NRPS.Pagination` + - `Lti_1p3.Platform.Services.NRPS.Errors` + - Reused helpers extracted from tool implementation (for example: link parsing and parameter normalization). +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none in library core. +- Data lifecycle: request-scoped response structs with host-managed data retrieval. +- Migration/backfill strategy: additive API and module updates with migration notes if reusable helpers replace existing logic. + +## 5. Interfaces and Contracts +- `authorize_request(claims, context) -> :ok | {:error, error}` +- `list_memberships(ctx, opts) -> {:ok, %MembershipPage{}} | {:error, error}` +- `validate_filters(opts, supported_filters) -> :ok | {:error, error}` +- `build_pagination_links(page_meta, opts) -> map()` + +## 6. Runtime Behavior +- Stateless orchestration and concurrent-safe request handling. +- No implicit retries in read path. +- Configurable page-size limits and default ordering behavior. + +## 7. Security and Compliance +- Scope/context/deployment checks prior to retrieval. +- PII redaction support in logs. +- Stable reason atoms for authorization and validation failures. + +## 8. Observability and Operations +- Metrics: + - `platform_nrps.request.count` + - `platform_nrps.page.count` + - `platform_nrps.denied.count` + - `platform_nrps.error.count` +- Logs: context, filters, page parameters, result count, reason. +- Tracing: telemetry spans around authorization and retrieval. + +## 9. Testing Strategy +- Unit: scope policy, filter validation, pagination link generation, error mapping. +- Integration: success, denial, invalid filter, and paging flows. +- End-to-end: tool-to-platform NRPS interoperability scenarios. +- Load/failure: high-volume pagination behavior. + +## 10. Open Questions +- Which tool NRPS helper modules should be extracted before platform phase 2 to maximize reuse without premature abstraction? diff --git a/docs/features/platform-nrps/plan.md b/docs/features/platform-nrps/plan.md new file mode 100644 index 0000000..777a65c --- /dev/null +++ b/docs/features/platform-nrps/plan.md @@ -0,0 +1,71 @@ +# Implementation Plan + +## Phase 0 - NRPS Platform Baseline +### Deliverables +- NRPS platform requirement matrix and target API signatures. + +### Tasks +- [ ] Define scope/context authorization matrix and filter capability matrix. +- [ ] Review tool NRPS implementation for reusable helper candidates. +- [ ] Finalize membership/page models and reason atom catalog. +- [ ] Finalize platform NRPS docs inventory. + +### Verification +- [ ] Requirement matrix approved. +- [ ] Reusable helper candidate list approved. + +## Phase 1 - Authorization and Core Models +### Deliverables +- Authorization pipeline and response model definitions. + +### Tasks +- [ ] Implement scope/context/deployment authorization helpers. +- [ ] Implement membership/page models and filter validation. +- [ ] Normalize all public error mappings. +- [ ] Add unit tests for authorization/filter/error branches. + +### Verification +- [ ] Authorization and filter tests pass. +- [ ] Error mapping tests pass. + +## Phase 2 - Membership Operations and Reuse +### Deliverables +- Membership listing operations with pagination/filter support and minimized duplication. + +### Tasks +- [ ] Implement paginated membership retrieval. +- [ ] Implement filter application and deterministic unsupported-filter behavior. +- [ ] Extract duplicated logic from tool NRPS into reusable modules and adopt those modules in platform NRPS. +- [ ] Add integration tests for success and denial paths. + +### Verification +- [ ] Integration tests pass for listing/filter/denial flows. +- [ ] Duplicated helper implementations are replaced by reusable modules where applicable. + +## Phase 3 - Hardening and Documentation +### Deliverables +- Production-ready observability and docs. + +### Tasks +- [ ] Add telemetry events and structured logs. +- [ ] Validate reusable module usage across tool/platform NRPS flows. +- [ ] Update docs with platform NRPS examples and integration guidance. +- [ ] Ensure `@moduledoc`, `@doc`, and `@spec` coverage. + +### Verification +- [ ] Telemetry assertions pass. +- [ ] `mix docs` completes with current platform NRPS guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual NRPS platform interoperability report. + +### Tasks +- [ ] Validate platform NRPS behavior with at least 2 reference tools. +- [ ] Validate pagination and unsupported-filter behavior. +- [ ] Validate high-volume and denial-path scenarios. +- [ ] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/platform-nrps/prd.md b/docs/features/platform-nrps/prd.md new file mode 100644 index 0000000..c98381e --- /dev/null +++ b/docs/features/platform-nrps/prd.md @@ -0,0 +1,75 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Platform NRPS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Platform-side NRPS service behavior requires complete implementation guidance for authorization, pagination, and filter handling. +- Why now: Certification and production tool interoperability require robust roster endpoint behavior. + +### Current State Analysis +- Platform NRPS operations are not fully implemented. +- Authorization and filter semantics need complete coverage. +- Tool NRPS will be implemented first and should drive reusable module extraction where duplication appears. + +## 3. Goals and Non-Goals +### Goals +- Implement platform NRPS membership listing behavior with scope/context checks. +- Support pagination and configured filters with deterministic semantics. +- Return structured errors and telemetry metadata. +- Reuse modules extracted from tool NRPS when equivalent logic already exists. +- Publish complete platform NRPS documentation. + +### Non-Goals +- Built-in SIS synchronization. +- Institution-specific role mapping policy defaults. + +## 4. Users and Primary Use Cases +- Personas: Platform developers exposing roster services to tools. +- Core scenarios: + - Authorize `contextmembership.readonly` access. + - Serve memberships with page and filter controls. + - Return explicit denials for unauthorized requests. + +## 5. Functional Requirements +1. Implement platform NRPS membership and page models. +2. Enforce scope/deployment/context checks before retrieval. +3. Support pagination metadata and link generation. +4. Support configured role/status/resource-link filters. +5. Return structured errors with stable reason atoms and HTTP mapping. +6. Emit telemetry/logs for request, result size, denials, and failures. +7. Extract duplicated helpers from tool NRPS into reusable modules and consume them in platform NRPS. +8. Document platform NRPS APIs and integration guidance. + +## 6. Non-Functional Requirements +- Reliability: deterministic page boundaries and repeatable results. +- Performance: bounded page size and query behavior. +- Security/Compliance: strict authorization checks and PII-aware logging. +- Documentation: complete ExDoc and integration guides. + +## 7. Success Metrics +- Platform NRPS conformance scenarios pass certification matrix. +- Interoperability verified with at least 2 reference tools. +- >= 90% coverage for platform NRPS modules. +- Cross-role duplicate logic is reduced through extracted reusable modules. + +## 8. Dependencies and Constraints +- Internal dependencies: platform token/scope validation, tool NRPS extracted reusable helpers. +- External dependencies: host application membership data source behavior. +- Constraints: framework-agnostic APIs and stable tagged-tuple return shapes. + +## 9. Risks and Mitigations +- Risk: duplicate pagination/filter logic drifts between roles. +- Mitigation: extract shared utility modules from tool implementation before finalizing platform operations. +- Risk: filter semantics mismatch across environments. +- Mitigation: explicit supported filter configuration and deterministic errors. + +## 10. Acceptance Criteria +1. Given authorized scope/context, platform returns paginated memberships. +2. Given unsupported or unauthorized requests, platform returns explicit structured errors. +3. Given duplicate logic already solved in tool NRPS, platform consumes extracted reusable modules. +4. Given telemetry instrumentation, request/result/denial metrics are emitted. +5. Given docs generation, platform NRPS API and guide docs are complete. diff --git a/docs/features/tool-ags/fdd.md b/docs/features/tool-ags/fdd.md new file mode 100644 index 0000000..0d7e901 --- /dev/null +++ b/docs/features/tool-ags/fdd.md @@ -0,0 +1,71 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Tool AGS claim parsing, scope checks, request/response normalization, observability. +- Assumptions: + - Caller provides access tokens. + - HTTP adapter remains configurable. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Tool.Services.AGS` public APIs. + - Typed AGS structs (endpoint, line item, score, result, page). + - Scope policy, parser, compatibility policy, and error normalization. +- Out-of-scope components: + - Platform AGS endpoint service behavior. + - Persistent grade storage. + +## 3. Architecture +- High-level flow: + - Parse AGS claim -> scope preflight -> build HTTP request -> execute transport -> normalize payload. +- Context/module responsibilities: + - `Lti_1p3.Tool.Services.AGS` + - `Lti_1p3.Tool.Services.AGS.ScopePolicy` + - `Lti_1p3.Tool.Services.AGS.Client` + - `Lti_1p3.Tool.Services.AGS.Parser` + - `Lti_1p3.Tool.Services.AGS.Errors` + - `Lti_1p3.Tool.Services.AGS.CompatibilityPolicy` +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none. +- Data lifecycle: endpoint config and parsed payload structs are request-scoped. +- Migration/backfill strategy: additive APIs with migration notes for renamed helpers. + +## 5. Interfaces and Contracts +- `from_launch_claim(claim_map) -> {:ok, %AgsEndpoint{}} | {:error, error}` +- `list_line_items(endpoint, token, opts) -> {:ok, %Page{}} | {:error, error}` +- `create_line_item(endpoint, token, attrs) -> {:ok, %LineItem{}} | {:error, error}` +- `update_line_item(line_item_url, token, attrs) -> {:ok, %LineItem{}} | {:error, error}` +- `delete_line_item(line_item_url, token) -> :ok | {:error, error}` +- `post_score(line_item_url, token, %Score{}) -> :ok | {:error, error}` +- `list_results(line_item_url, token, opts) -> {:ok, %Page{}} | {:error, error}` + +## 6. Runtime Behavior +- Stateless, concurrent-safe operations. +- Optional caller-configured retry policy. +- Configurable timeout and bounded backoff. + +## 7. Security and Compliance +- Required scope checks before outbound calls. +- Redaction of bearer tokens in logs and telemetry metadata. +- Stable reason atoms for authorization and validation failures. + +## 8. Observability and Operations +- Metrics: + - `tool_ags.request.count` + - `tool_ags.request.duration` + - `tool_ags.error.count` + - `tool_ags.scope.denied.count` +- Logs: operation, host, status class, reason. +- Tracing: telemetry span per AGS request. + +## 9. Testing Strategy +- Unit: scope policy, parsers, error mapping, compatibility policy. +- Integration: line item/score/result flows with success and failure paths. +- End-to-end: launch claim to score publish and result retrieval flow. +- Load/failure: timeout and transient failure classification. + +## 10. Open Questions +- Which utility namespace will host extracted HTTP/pagination helpers after tool implementation proves duplication (`Lti_1p3.Services.Common.*` vs `Lti_1p3.Internal.*`)? diff --git a/docs/features/tool-ags/plan.md b/docs/features/tool-ags/plan.md new file mode 100644 index 0000000..7d9f9fe --- /dev/null +++ b/docs/features/tool-ags/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - AGS Tool Baseline +### Deliverables +- AGS tool requirement matrix and finalized API surface. + +### Tasks +- [ ] Map AGS tool operations and required scopes. +- [ ] Audit current implementation and enumerate missing behavior. +- [ ] Finalize typed models and error reason catalog. +- [ ] Define candidate utility extraction seams in tool modules. + +### Verification +- [ ] Requirement matrix approved. +- [ ] API signatures and reason atoms approved. + +## Phase 1 - Models, Parsing, and Scope Enforcement +### Deliverables +- Typed structs and deterministic preflight authorization. + +### Tasks +- [ ] Implement/refresh endpoint, line item, score, result, and page structs. +- [ ] Implement claim parser and scope policy. +- [ ] Normalize all public errors to structured maps. +- [ ] Add unit tests for parsing/scope/error branches. + +### Verification +- [ ] Unit tests pass for parser, scope policy, and error mapping. +- [ ] Existing behavior regressions are covered. + +## Phase 2 - Tool AGS Operations +### Deliverables +- Full line item, score, and result operation coverage. + +### Tasks +- [ ] Implement line item list/create/read/update/delete operations. +- [ ] Implement score posting validation and execution. +- [ ] Implement result retrieval and pagination traversal. +- [ ] Implement compatibility profile hooks. + +### Verification +- [ ] Integration tests pass for success/failure branches. +- [ ] Pagination and scope matrix tests pass. + +## Phase 3 - Hardening and Extraction Readiness +### Deliverables +- Production-ready observability plus reusable module extraction plan. + +### Tasks +- [ ] Add telemetry events and structured logs. +- [ ] Document utility candidates with concrete call sites and tests. +- [ ] Extract proven duplicate helpers into reusable modules where appropriate. +- [ ] Update docs with tool AGS examples and utility usage notes. + +### Verification +- [ ] Telemetry assertions pass. +- [ ] Reusable helpers are covered by focused unit tests. +- [ ] `mix docs` completes with current guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual AGS tool interoperability report. + +### Tasks +- [ ] Validate line item lifecycle in at least 2 LMS sandboxes. +- [ ] Validate score publish and result retrieval paths. +- [ ] Validate denial/error paths (scope, token, payload, timeout). +- [ ] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/tool-ags/prd.md b/docs/features/tool-ags/prd.md new file mode 100644 index 0000000..b387846 --- /dev/null +++ b/docs/features/tool-ags/prd.md @@ -0,0 +1,77 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Tool AGS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Tool-side AGS behavior is partial and misses full line item, score, and result workflows. +- Why now: Certification readiness depends on complete, spec-aligned AGS client behavior. + +### Current State Analysis +- `Lti_1p3.Tool.Services.AGS` provides partial operations. +- Scope enforcement is inconsistent per operation. +- Result pagination traversal is incomplete. +- Error metadata and tests are not complete across failure paths. + +## 3. Goals and Non-Goals +### Goals +- Deliver full tool AGS operations for line items, scores, and results. +- Enforce operation-to-scope checks with stable reason atoms. +- Return typed structs and structured error maps for all public operations. +- Add telemetry and compatibility controls for LMS variance. +- Define reusable utility extraction points during tool implementation for later platform reuse. + +### Non-Goals +- Platform-hosted AGS endpoint implementation. +- Library-managed persistent grade storage. + +## 4. Users and Primary Use Cases +- Personas: Tool developers posting grades and reading results. +- Core scenarios: + - Parse AGS launch claims and perform line item CRUD. + - Post scores for a line item. + - Retrieve results across pages. + +## 5. Functional Requirements +1. Parse AGS claims into typed endpoint configuration. +2. Implement list/create/read/update/delete line item operations. +3. Implement score posting with payload validation. +4. Implement results retrieval with pagination traversal. +5. Enforce required scopes before outbound calls. +6. Return `%{reason:, operation:, http_status:, retryable:, msg:}` errors. +7. Emit telemetry for request outcomes and scope denials. +8. Document all public tool AGS APIs. +9. Identify duplicate-prone logic (headers, paging links, request normalization) and design it for extractable module boundaries. + +## 6. Non-Functional Requirements +- Reliability: deterministic outcomes for identical inputs. +- Performance: low local overhead excluding network latency. +- Security/Compliance: token redaction and strict scope validation. +- Documentation: complete ExDoc and integration guide coverage. + +## 7. Success Metrics +- Tool AGS conformance scenarios pass certification matrix. +- Interoperability verified with at least 2 LMS sandboxes. +- >= 90% coverage for tool AGS modules. +- Reusable utility candidates are documented with test coverage before platform AGS implementation starts. + +## 8. Dependencies and Constraints +- Internal dependencies: token handling, HTTP transport abstraction, launch claim parsing. +- External dependencies: LMS AGS endpoint behavior variance. +- Constraints: framework-agnostic API design and stable tagged-tuple return shapes. + +## 9. Risks and Mitigations +- Risk: LMS inconsistencies cause fragile integrations. +- Mitigation: compatibility profile options and explicit failure reason atoms. +- Risk: retries can duplicate writes. +- Mitigation: default no automatic retry for write operations. + +## 10. Acceptance Criteria +1. Given valid AGS claims/scopes, all tool operations return typed success tuples. +2. Given insufficient scopes, operations return `{:error, %{reason: :insufficient_scope, ...}}`. +3. Given score publish requests, response handling is normalized. +4. Given result retrieval, pagination supports complete traversal. +5. Given docs generation, tool AGS API and guide docs are complete. diff --git a/docs/features/tool-deep-linking/fdd.md b/docs/features/tool-deep-linking/fdd.md new file mode 100644 index 0000000..bffc530 --- /dev/null +++ b/docs/features/tool-deep-linking/fdd.md @@ -0,0 +1,68 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Tool deep-link request validation, settings parsing, content item modeling, response signing. +- Assumptions: + - Launch dispatch supports deep-link message routing. + - Signing key retrieval utilities are available. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Tool.DeepLinking.RequestValidator` + - `Lti_1p3.Tool.DeepLinking.Settings` + - `Lti_1p3.Tool.DeepLinking.ContentItem` + - `Lti_1p3.Tool.DeepLinking.ResponseBuilder` + - `Lti_1p3.Tool.DeepLinking.Errors` +- Out-of-scope components: + - Platform deep-link response validation. + - Tool authoring UI behavior. + +## 3. Architecture +- High-level flow: + - Validate launch claims -> parse settings -> validate/build content items -> assemble/sign response JWT. +- Context/module responsibilities: + - `Lti_1p3.Tool.DeepLinking.RequestValidator` + - `Lti_1p3.Tool.DeepLinking.Settings` + - `Lti_1p3.Tool.DeepLinking.ContentItem` + - `Lti_1p3.Tool.DeepLinking.ResponseBuilder` + - `Lti_1p3.Tool.DeepLinking.CompatibilityPolicy` + - `Lti_1p3.Tool.DeepLinking.Errors` +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none. +- Data lifecycle: request/settings/items/response structs are request-scoped. +- Migration/backfill strategy: additive APIs and notes for helper refactors. + +## 5. Interfaces and Contracts +- `validate_request(launch_claims) -> {:ok, %DeepLinkingRequest{}} | {:error, error}` +- `content_item(type, attrs) -> {:ok, %ContentItem{}} | {:error, error}` +- `build_response(request, items, opts) -> {:ok, %{jwt: token, return_url: url}} | {:error, error}` + +## 6. Runtime Behavior +- Stateless, concurrent-safe operations. +- No outbound HTTP in core validation/build path. +- Caller handles transport to deep-link return URL. + +## 7. Security and Compliance +- Validate issuer/audience/message type from launch context. +- Enforce conditional `data` correlation rules. +- Redact sensitive values in logs and telemetry metadata. + +## 8. Observability and Operations +- Metrics: + - `tool_deep_linking.request.valid_count` + - `tool_deep_linking.response.build_count` + - `tool_deep_linking.error.count` +- Logs: issuer/client_id, item count, reason on failure. +- Tracing: telemetry spans around validation/build operations. + +## 9. Testing Strategy +- Unit: claim validation, settings parsing, item subtype validation, error mapping. +- Integration: signed JWT claims and signature verification paths. +- End-to-end: deep-link request to response generation flow. +- Load/failure: large item list validation and signing behavior. + +## 10. Open Questions +- Which tool deep-linking helpers should be extracted before platform phase 2 (claim correlation helpers, content item normalization, error mappers)? diff --git a/docs/features/tool-deep-linking/plan.md b/docs/features/tool-deep-linking/plan.md new file mode 100644 index 0000000..921b150 --- /dev/null +++ b/docs/features/tool-deep-linking/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - Deep Linking Tool Baseline +### Deliverables +- Deep-linking tool requirement matrix and target API signatures. + +### Tasks +- [x] Map deep-linking tool requirements and current gaps. +- [x] Finalize request/settings/item/response API contracts. +- [x] Finalize reason atom catalog for validation/build failures. +- [x] Identify utility extraction seams in tool modules. + +### Verification +- [x] Requirement matrix approved. +- [x] API signatures and reason atoms approved. + +## Phase 1 - Request Validation and Settings +### Deliverables +- Launch validation and typed settings parsing. + +### Tasks +- [x] Implement request validator and settings parser. +- [x] Integrate deep-linking request validation into dispatch. +- [x] Normalize errors to structured maps. +- [x] Add unit tests for positive and negative claim paths. + +### Verification +- [x] Dispatch and validation tests pass. +- [x] Structured error mapping tests pass. + +## Phase 2 - Content Items and Response Builder +### Deliverables +- Typed content item API and signed deep-link response generation. + +### Tasks +- [x] Implement content item builders and subtype validation. +- [x] Implement response claim assembly and conditional `data` handling. +- [x] Integrate JWT signing helper. +- [x] Implement compatibility profile hooks. + +### Verification +- [x] Integration tests pass for response claims/signatures. +- [x] Unsupported subtype behavior tests pass. + +## Phase 3 - Hardening and Extraction Readiness +### Deliverables +- Production-ready observability plus reusable module extraction plan. + +### Tasks +- [x] Add telemetry events and structured logs. +- [x] Document utility candidates with concrete call sites and tests. +- [x] Extract proven duplicate helpers into reusable modules where appropriate. +- [x] Update docs with tool deep-linking examples and utility usage notes. + +### Verification +- [x] Telemetry assertions pass. +- [x] Reusable helpers are covered by focused unit tests. +- [x] `mix docs` completes with current guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual deep-linking tool interoperability report. + +### Tasks +- [ ] Validate tool behavior against at least 2 LMS sandboxes. +- [ ] Validate response submission with representative item types. +- [ ] Validate negative paths (missing claims, disabled types, bad signatures). +- [x] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/tool-deep-linking/prd.md b/docs/features/tool-deep-linking/prd.md new file mode 100644 index 0000000..f904090 --- /dev/null +++ b/docs/features/tool-deep-linking/prd.md @@ -0,0 +1,76 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Tool Deep Linking 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Tool deep-linking support is incomplete for request validation and response generation. +- Why now: Deep linking interoperability and certification require complete tool behavior. + +### Current State Analysis +- Request validation is not complete in dispatch. +- Response builder/signing support is incomplete. +- Content item validation and error semantics are inconsistent. + +## 3. Goals and Non-Goals +### Goals +- Validate `LtiDeepLinkingRequest` launches with typed settings. +- Build and sign deep-linking response JWTs with validated content items. +- Preserve deterministic error reason semantics. +- Add telemetry and compatibility controls for LMS variance. +- Design utility extraction seams in tool implementation for later platform reuse. + +### Non-Goals +- Tool UI for content selection. +- Platform response consumption behavior. + +## 4. Users and Primary Use Cases +- Personas: Tool developers returning selected content items. +- Core scenarios: + - Validate deep-linking launch claims. + - Build content items. + - Build/sign response JWT for return URL submission. + +## 5. Functional Requirements +1. Implement validator for `LtiDeepLinkingRequest` claims. +2. Parse deep-linking settings into typed structs. +3. Implement content item builders and subtype validation. +4. Implement response claim builder and JWT signing helper integration. +5. Enforce conditional `data` correlation rules. +6. Return structured reasoned errors for validation/build failures. +7. Emit telemetry for request validation and response build outcomes. +8. Document public tool deep-linking APIs and guides. +9. Identify duplicated logic candidates and define extractable module boundaries for platform reuse. + +## 6. Non-Functional Requirements +- Reliability: deterministic output for identical inputs. +- Performance: bounded local validation/build processing. +- Security/Compliance: strict claim checks and signing-key handling. +- Documentation: complete ExDoc and guide coverage. + +## 7. Success Metrics +- Tool deep-linking conformance scenarios pass certification matrix. +- Interoperability verified with at least 2 LMS sandboxes. +- >= 90% coverage for tool deep-linking modules. +- Reusable utility candidates are documented and tested before platform deep-linking implementation starts. + +## 8. Dependencies and Constraints +- Internal dependencies: launch validation, signing utilities, claims modules. +- External dependencies: LMS item subtype acceptance variance. +- Constraints: framework-agnostic API design and stable tagged-tuple shapes. + +## 9. Risks and Mitigations +- Risk: platform-specific subtype restrictions. +- Mitigation: compatibility policy with deterministic fallback/error behavior. +- Risk: request/response data mismatch. +- Mitigation: explicit data correlation tests. + +## 10. Acceptance Criteria +1. Given deep-linking launch claims, validation returns typed request/settings structs. +2. Given valid items, response builder returns signed JWT with required claims. +3. Given malformed items/claims, APIs return structured reasoned errors. +4. Given configured compatibility behavior, subtype variance handling is deterministic. +5. Given docs generation, tool deep-linking API and guide docs are complete. diff --git a/docs/features/tool-deep-linking/qa_report.md b/docs/features/tool-deep-linking/qa_report.md new file mode 100644 index 0000000..31eb623 --- /dev/null +++ b/docs/features/tool-deep-linking/qa_report.md @@ -0,0 +1,40 @@ +# Manual QA Interoperability Report + +## Date + +- 2026-03-05 + +## Scope + +- Tool deep-linking request validation +- Content item creation +- Deep-linking response JWT generation/signing +- Negative-path behavior + +## Automated Validation Completed + +1. `mix test` +- Result: pass (`122 tests, 0 failures`) + +2. `mix docs` +- Result: pass (documentation generated) + +## Manual LMS Sandbox Validation + +- Status: pending external execution. +- Required environments: + 1. IMS Reference Implementation (tool launch + deep-link return) + 2. Second LMS sandbox (for example Canvas test instance) + +## Negative Paths Verified Locally + +- Missing/invalid deep-linking settings claim handling. +- Unsupported content item types. +- Compatibility filtering behaviors. +- Signing key absence and invalid content item list handling. + +## Findings and Ownership + +- No critical local defects found in automated tests. +- Outstanding manual interoperability execution is required before feature closure. +- Owner: Lti_1p3 maintainers. diff --git a/docs/features/tool-deep-linking/requirements_matrix.md b/docs/features/tool-deep-linking/requirements_matrix.md new file mode 100644 index 0000000..1725286 --- /dev/null +++ b/docs/features/tool-deep-linking/requirements_matrix.md @@ -0,0 +1,13 @@ +# Tool Deep Linking Requirement Matrix + +| Requirement | Status | Primary Modules | Tests | +| --- | --- | --- | --- | +| Validate `LtiDeepLinkingRequest` claims | Implemented | `Lti_1p3.Tool.DeepLinking.RequestValidator`, `Lti_1p3.Tool.MessageValidators.DeepLinkingMessageValidator` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Parse typed deep-linking settings | Implemented | `Lti_1p3.Tool.DeepLinking.Settings` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Build typed content items | Implemented | `Lti_1p3.Tool.DeepLinking.ContentItem` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Build/sign deep-linking response JWT | Implemented | `Lti_1p3.Tool.DeepLinking.ResponseBuilder`, `Lti_1p3.Tool.DeepLinking.JwtSigner` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Conditional response `data` handling | Implemented | `Lti_1p3.Tool.DeepLinking.ResponseBuilder` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Compatibility controls for subtype variance | Implemented | `Lti_1p3.Tool.DeepLinking.CompatibilityPolicy` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Structured errors for failures | Implemented | `Lti_1p3.Tool.DeepLinking.Errors` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Telemetry/logging for request and response outcomes | Implemented | `Lti_1p3.Tool.DeepLinking.Telemetry` | `test/lti_1p3/tool/deep_linking_test.exs` | +| Utility extraction seams for platform reuse | Implemented | `Lti_1p3.DeepLinking.ClaimKeys` | `test/lti_1p3/tool/deep_linking_test.exs` | diff --git a/docs/features/tool-deep-linking/utility_extraction.md b/docs/features/tool-deep-linking/utility_extraction.md new file mode 100644 index 0000000..37c5e6f --- /dev/null +++ b/docs/features/tool-deep-linking/utility_extraction.md @@ -0,0 +1,18 @@ +# Utility Extraction Candidates + +## Extracted Helpers + +1. `Lti_1p3.DeepLinking.ClaimKeys` +- Purpose: shared claim-key constants and lookup for deep-linking claims. +- Tool call sites: + - `Lti_1p3.Tool.DeepLinking.RequestValidator` + - `Lti_1p3.Tool.DeepLinking.ResponseBuilder` + - `Lti_1p3.Tool.MessageValidators.DeepLinkingMessageValidator` +- Tests: + - `test/lti_1p3/tool/deep_linking_test.exs` + +## Next Reuse Targets (Platform Deep Linking) + +1. Content item normalization and type alias handling (`ContentItem.normalize_type/1` logic shape). +2. Response/data correlation guard patterns (`ResponseBuilder.maybe_put_data/2` contract). +3. Deep-linking error reason catalog harmonization (`Tool.DeepLinking.Errors` reason atoms). diff --git a/docs/features/tool-nrps/fdd.md b/docs/features/tool-nrps/fdd.md new file mode 100644 index 0000000..de073dd --- /dev/null +++ b/docs/features/tool-nrps/fdd.md @@ -0,0 +1,67 @@ +# Functional Design Document + +## 1. Design Overview +- Scope covered: Tool NRPS claim parsing, scope checks, pagination traversal, membership normalization. +- Assumptions: + - Caller provides access tokens. + - HTTP adapter remains configurable. + +## 2. System Context and Boundaries +- In-scope components: + - `Lti_1p3.Tool.Services.NRPS` public APIs. + - Endpoint/membership/page structs. + - Scope policy, parser, client, and error normalization. +- Out-of-scope components: + - Platform membership service behavior. + - Persistent roster storage. + +## 3. Architecture +- High-level flow: + - Parse claim -> scope preflight -> request page -> parse memberships -> continue traversal when requested. +- Context/module responsibilities: + - `Lti_1p3.Tool.Services.NRPS` + - `Lti_1p3.Tool.Services.NRPS.ScopePolicy` + - `Lti_1p3.Tool.Services.NRPS.Client` + - `Lti_1p3.Tool.Services.NRPS.Parser` + - `Lti_1p3.Tool.Services.NRPS.Errors` +- Supervision tree impact: + - No new long-lived processes. + +## 4. Data Design +- Schema changes: none. +- Data lifecycle: endpoint, page, and membership structs are request-scoped. +- Migration/backfill strategy: additive changes with notes for renamed helpers. + +## 5. Interfaces and Contracts +- `from_launch_claim(claim_map) -> {:ok, %NrpsEndpoint{}} | {:error, error}` +- `list_memberships(endpoint, token, opts) -> {:ok, %MembershipPage{}} | {:error, error}` +- `stream_memberships(endpoint, token, opts) -> Enumerable.t()` +- `fetch_all_memberships(endpoint, token, opts) -> {:ok, [%Membership{}]} | {:error, error}` + +## 6. Runtime Behavior +- Stateless operations safe for concurrency. +- Optional bounded retry for transient failures. +- Configurable timeout and max-page safeguards. + +## 7. Security and Compliance +- Enforce `contextmembership.readonly` scope checks before requests. +- Optional PII field redaction in logs. +- Stable reason atoms for auth/validation failures. + +## 8. Observability and Operations +- Metrics: + - `tool_nrps.request.count` + - `tool_nrps.page.count` + - `tool_nrps.membership.count` + - `tool_nrps.error.count` +- Logs: page index, member count, reason. +- Tracing: telemetry span around page fetch and parse. + +## 9. Testing Strategy +- Unit: scope constants, link parser, role normalization, error mapping. +- Integration: multi-page traversal, filters, retries, and failures. +- End-to-end: launch claim to full roster retrieval flow. +- Load/failure: large roster traversal with max-page guards. + +## 10. Open Questions +- Which helper boundaries are best for extraction after tool NRPS implementation (link parsing, filter normalization, error mapping)? diff --git a/docs/features/tool-nrps/plan.md b/docs/features/tool-nrps/plan.md new file mode 100644 index 0000000..85977e5 --- /dev/null +++ b/docs/features/tool-nrps/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan + +## Phase 0 - NRPS Tool Baseline +### Deliverables +- NRPS tool requirement matrix and target API signatures. + +### Tasks +- [ ] Map NRPS requirements, scope matrix, and current gaps. +- [ ] Finalize page/stream/fetch-all API contracts. +- [ ] Finalize typed models and reason atom catalog. +- [ ] Identify utility extraction seams for link/filter helpers. + +### Verification +- [ ] Requirement matrix approved. +- [ ] API contracts and reason atoms approved. + +## Phase 1 - Models, Parsing, and Scope Enforcement +### Deliverables +- Typed endpoint/membership/page models and deterministic scope checks. + +### Tasks +- [ ] Implement claim parser and endpoint model validation. +- [ ] Implement membership model and role normalization. +- [ ] Implement scope preflight and structured errors. +- [ ] Add unit tests for parser/scope/error branches. + +### Verification +- [ ] Unit tests pass for parser/scope/normalization. +- [ ] Regression tests cover known scope issues. + +## Phase 2 - Pagination and Retrieval APIs +### Deliverables +- Full pagination traversal with stream and eager retrieval modes. + +### Tasks +- [ ] Implement `Link` header parser and page traversal. +- [ ] Implement stream API. +- [ ] Implement eager fetch-all with max-page guard. +- [ ] Implement optional filter and limit support. + +### Verification +- [ ] Integration tests pass for multi-page and filter paths. +- [ ] Max-page guard behavior is verified. + +## Phase 3 - Hardening and Extraction Readiness +### Deliverables +- Production-ready observability plus reusable module extraction plan. + +### Tasks +- [ ] Add telemetry events and structured logs. +- [ ] Document utility candidates with concrete call sites and tests. +- [ ] Extract proven duplicate helpers into reusable modules where appropriate. +- [ ] Update docs with tool NRPS examples and utility usage notes. + +### Verification +- [ ] Telemetry/retry tests pass. +- [ ] Reusable helpers are covered by focused unit tests. +- [ ] `mix docs` completes with current guides. + +## Phase 4 - Manual QA Acceptance Testing +### Deliverables +- Manual NRPS tool interoperability report. + +### Tasks +- [ ] Validate roster retrieval with at least 2 LMS sandboxes. +- [ ] Validate pagination and filters. +- [ ] Validate negative paths (scope denial, malformed payload, token expiry). +- [ ] Record QA findings and remediation ownership. + +### Verification +- [ ] QA report approved. +- [ ] Critical defects triaged. diff --git a/docs/features/tool-nrps/prd.md b/docs/features/tool-nrps/prd.md new file mode 100644 index 0000000..7625804 --- /dev/null +++ b/docs/features/tool-nrps/prd.md @@ -0,0 +1,78 @@ +# Product Requirements Document + +## 1. Feature Summary +- Name: Tool NRPS 2.0 Complete Support +- Owner: Lti_1p3 Maintainers +- Last Updated: 2026-03-05 +- Status: Proposed + +## 2. Problem Statement +- Current pain: Tool NRPS behavior is incomplete for scope correctness, filtering, and page traversal. +- Why now: Certification and production roster synchronization require complete NRPS behavior. + +### Current State Analysis +- Current implementation is single-page oriented. +- Scope handling needs stricter correctness. +- Error shapes and retry metadata are inconsistent. +- Coverage is incomplete across pagination and filter paths. + +## 3. Goals and Non-Goals +### Goals +- Deliver full tool NRPS retrieval with pagination and filter support. +- Enforce required NRPS scopes per operation. +- Normalize memberships into typed structures. +- Provide page, stream, and eager full-fetch APIs. +- Define reusable module extraction points during tool implementation for platform reuse. + +### Non-Goals +- Built-in roster persistence. +- Institution-specific roster reconciliation workflows. + +## 4. Users and Primary Use Cases +- Personas: Tool developers retrieving class rosters. +- Core scenarios: + - Parse NRPS launch claims and validate scopes. + - Fetch memberships across pages. + - Apply supported role/status/resource-link filters. + +## 5. Functional Requirements +1. Parse NRPS launch claim into typed endpoint configuration. +2. Validate required NRPS scopes before requests. +3. Support `Link` header traversal for multi-page responses. +4. Support optional query filters and limits. +5. Normalize memberships to typed structs. +6. Provide stream and eager fetch-all APIs. +7. Return structured errors with retryability hints. +8. Emit telemetry for requests, pages, and failures. +9. Document all public tool NRPS APIs. +10. Identify duplicate-prone logic and define extractable boundaries for later platform reuse. + +## 6. Non-Functional Requirements +- Reliability: deterministic paging behavior and bounded retries. +- Performance: avoid unbounded memory growth on large rosters. +- Security/Compliance: strict scope checks and configurable PII redaction. +- Documentation: complete ExDoc and guide coverage. + +## 7. Success Metrics +- Tool NRPS conformance scenarios pass certification matrix. +- Interoperability verified with at least 2 LMS sandboxes. +- >= 90% coverage for tool NRPS modules. +- Reusable utility candidates are documented and tested before platform NRPS implementation starts. + +## 8. Dependencies and Constraints +- Internal dependencies: launch claims, HTTP transport, role helpers. +- External dependencies: LMS pagination/filter differences. +- Constraints: framework-agnostic API design and stable tagged-tuple shapes. + +## 9. Risks and Mitigations +- Risk: LMS pagination variance causes brittle traversal. +- Mitigation: tolerant parser with explicit failure reasons. +- Risk: large rosters increase memory pressure. +- Mitigation: stream API and max-page guardrails. + +## 10. Acceptance Criteria +1. Given valid claim/scope, tool retrieves memberships across pages. +2. Given missing scope, APIs return `:insufficient_scope` errors. +3. Given supported filters, memberships are filtered as requested. +4. Given failures, structured errors include operation/status/retryability. +5. Given docs generation, tool NRPS API and guide docs are complete. diff --git a/docs/generated/db-schema.md b/docs/generated/db-schema.md new file mode 100644 index 0000000..541c12e --- /dev/null +++ b/docs/generated/db-schema.md @@ -0,0 +1,5 @@ +# Database Schema + +This repository does not ship a first-party database schema. + +Persistence is provided through behavior contracts so host applications can choose their own storage model. If this project later adds generated schema artifacts for an official provider, document them here. diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md new file mode 100644 index 0000000..f819cb4 --- /dev/null +++ b/docs/product-specs/index.md @@ -0,0 +1,9 @@ +# Product Specs Index + +Library feature specifications belong under `docs/exec-plans/current//` while active and may later be archived under `docs/exec-plans/archive/features//` with: + +- `prd.md` +- `fdd.md` +- `plan.md` + +Use this index as the entry point when additional product-spec metadata or cataloging is added. diff --git a/docs/provider_adapter_migration.md b/docs/provider_adapter_migration.md new file mode 100644 index 0000000..0b6e3eb --- /dev/null +++ b/docs/provider_adapter_migration.md @@ -0,0 +1,29 @@ +# Provider Adapter Migration Checklist + +Use this checklist when updating custom providers to match core contract changes. + +## ToolDataProvider contract checks + +- `get_registration_deployment/3` returns `{registration_or_nil, deployment_or_nil}`. +- `get_jwk_by_registration/1` returns: + - `{:ok, %Lti_1p3.Jwk{}}` + - `{:error, %Lti_1p3.DataProviderError{reason: :not_found | ...}}` + +## Error hygiene + +- Use stable `DataProviderError.reason` atoms. +- Keep error tuples and struct shapes deterministic. + +## Conformance tests + +Run provider contract tests after adapter updates: + +```bash +mix test test/lti_1p3/provider_contracts_test.exs +``` + +## Optional adapter regression checks + +- Tool launch validation with valid registration + deployment. +- Duplicate nonce rejection path. +- Platform authorization nonce reuse rejection path. diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..0cb06ef --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,64 @@ +# Telemetry + +This library emits `:telemetry` events for core validation flows so client applications can collect metrics, traces, and structured logs. + +## Event Names + +- Stage event: `[:lti_1p3, :core, :validation, :stage]` +- Outcome event: `[:lti_1p3, :core, :validation, :outcome]` + +## Measurements + +Both events currently emit: + +- `%{count: 1}` + +## Metadata + +Common metadata fields: + +- `:flow` - `:tool_launch` or `:platform_authorize_redirect` +- `:result` - `:ok` or `:error` +- `:correlation_id` - request correlation ID + +Additional fields when available: + +- `:stage` - validation stage atom (always present for stage events) +- `:reason` - reason atom for failures + +## Attaching a Client Handler + +Attach handlers in your application startup (for example, in your supervision tree init path): + +```elixir +:telemetry.attach_many( + "myapp-lti-telemetry", + [ + [:lti_1p3, :core, :validation, :stage], + [:lti_1p3, :core, :validation, :outcome] + ], + fn event, measurements, metadata, _config -> + # Forward to OpenTelemetry / PromEx / StatsD / Logger + Logger.info("lti telemetry", event: event, measurements: measurements, metadata: metadata) + end, + nil +) +``` + +## Correlation IDs + +You can pass a custom correlation ID through API opts: + +- `Lti_1p3.Tool.validate_launch(params, expected_state, correlation_id: "...")` +- `Lti_1p3.Platform.authorize_redirect(params, current_user, issuer, claims, correlation_id: "...")` + +If omitted, the library generates a UUID. + +## Logging Behavior + +The library also logs stage/outcome summaries via `Logger`. Client apps can control log verbosity with standard Logger config. + +## Stability Notes + +- Event names are considered part of the public observability contract. +- Measurements/metadata may gain additional fields in future minor releases. diff --git a/docs/tool_deep_linking_guide.md b/docs/tool_deep_linking_guide.md new file mode 100644 index 0000000..ee2d068 --- /dev/null +++ b/docs/tool_deep_linking_guide.md @@ -0,0 +1,86 @@ +# Tool Deep Linking Guide + +This guide covers tool-side deep-linking request validation and response generation. + +## Validate a Deep-Linking Request + +Use the claims from a validated launch to build a typed request: + +```elixir +{:ok, request} = + Lti_1p3.Tool.validate_deep_linking_request(launch.claims) +``` + +The request includes typed deep-linking settings (`deep_link_return_url`, accepted content types, accepted presentation targets, and optional `data`). + +## Build Content Items + +Build supported content item types with deterministic validation errors: + +```elixir +{:ok, item} = + Lti_1p3.Tool.deep_linking_content_item(:lti_resource_link, %{ + "url" => "https://tool.example.com/resources/42", + "title" => "Homework 1" + }) +``` + +Supported item types: + +- `:lti_resource_link` +- `:link` +- `:file` +- `:image` +- `:html` + +## Build and Sign a Response + +Generate a signed `LtiDeepLinkingResponse` JWT payload for form-post to the return URL: + +```elixir +{:ok, %{jwt: jwt, return_url: return_url}} = + Lti_1p3.Tool.build_deep_linking_response(request, [item]) +``` + +Compatibility behavior for LMS subtype variance can be controlled with options: + +```elixir +{:ok, payload} = + Lti_1p3.Tool.build_deep_linking_response(request, [item], + unsupported_type_strategy: :filter_unsupported + ) +``` + +## Error Shape + +All tool deep-linking APIs return deterministic error maps: + +```elixir +%{reason: atom(), stage: atom(), msg: String.t(), details: map()} +``` + +Common reasons include: + +- `:invalid_deep_linking_request` +- `:invalid_deep_linking_settings` +- `:unsupported_content_item_type` +- `:invalid_content_item` +- `:no_supported_content_items` +- `:response_signing_failed` + +## Telemetry + +Tool deep-linking emits: + +- `[:lti_1p3, :tool, :deep_linking, :request]` +- `[:lti_1p3, :tool, :deep_linking, :response]` + +Metadata includes `:result`, and when available, `:issuer`, `:client_id`, `:item_count`, and `:reason`. + +## Utility Reuse Notes + +Shared deep-linking claim keys are centralized in: + +- `Lti_1p3.DeepLinking.ClaimKeys` + +Tool modules consume this helper to reduce hard-coded claim key drift before platform deep-linking implementation. diff --git a/harness.yml b/harness.yml new file mode 100644 index 0000000..4890b88 --- /dev/null +++ b/harness.yml @@ -0,0 +1,31 @@ +version: 1 + +capabilities: + feature_flags: + adoption: disabled + default: exclude + details_file: docs/OPERATIONS.md + telemetry: + adoption: enabled + default: include + details_file: docs/OPERATIONS.md + performance_requirements: + adoption: enabled + default: exclude + details_file: docs/OPERATIONS.md + code_review: + adoption: enabled + default: include + details_file: docs/CODEREVIEW.md + issue_tracking: + adoption: enabled + default: include + details_file: docs/ISSUE_TRACKING.md + +providers: + observability: + name: none + issue_tracker: + name: none + +links: {} diff --git a/lib/lti_1p3/config.ex b/lib/lti_1p3/config.ex index 45e802f..1c42010 100644 --- a/lib/lti_1p3/config.ex +++ b/lib/lti_1p3/config.ex @@ -1,6 +1,5 @@ defmodule Lti_1p3.Config do alias Lti_1p3.KeyProviders.MemoryKeyProvider - alias Lti_1p3.Registration @moduledoc """ Methods for accessing lti_1p3 config @@ -36,7 +35,6 @@ defmodule Lti_1p3.Config do def default_config(), do: [ http_client: HTTPoison, - registration: Registration, key_provider: MemoryKeyProvider, # login_hints only persist for a day, 86400 seconds = 24 hours diff --git a/lib/lti_1p3/core/errors.ex b/lib/lti_1p3/core/errors.ex new file mode 100644 index 0000000..5da5a56 --- /dev/null +++ b/lib/lti_1p3/core/errors.ex @@ -0,0 +1,22 @@ +defmodule Lti_1p3.Core.Errors do + @moduledoc """ + Canonical error helpers for core tool/platform flows. + """ + + @type t() :: %{ + reason: atom(), + stage: atom(), + msg: String.t(), + details: map() + } + + @spec error(atom(), atom(), String.t(), map()) :: {:error, t()} + def error(stage, reason, msg, details \\ %{}) when is_atom(stage) and is_atom(reason) do + {:error, %{reason: reason, stage: stage, msg: msg, details: details}} + end + + @spec put_details(t(), map()) :: t() + def put_details(%{details: details} = error, extra_details) when is_map(extra_details) do + %{error | details: Map.merge(details, extra_details)} + end +end diff --git a/lib/lti_1p3/core/telemetry.ex b/lib/lti_1p3/core/telemetry.ex new file mode 100644 index 0000000..b1a4bc7 --- /dev/null +++ b/lib/lti_1p3/core/telemetry.ex @@ -0,0 +1,45 @@ +defmodule Lti_1p3.Core.Telemetry do + @moduledoc """ + Telemetry emitter for core validation flows. + """ + + require Logger + + @type metadata :: map() + + @spec emit_stage(atom(), :ok | :error, metadata()) :: :ok + def emit_stage(stage, result, metadata \\ %{}) do + metadata = Map.merge(%{stage: stage, result: result}, metadata) + + :telemetry.execute( + [:lti_1p3, :core, :validation, :stage], + %{count: 1}, + metadata + ) + + log_event(:stage, metadata) + :ok + end + + @spec emit_outcome(:ok | :error, metadata()) :: :ok + def emit_outcome(result, metadata \\ %{}) do + metadata = Map.merge(%{result: result}, metadata) + + :telemetry.execute( + [:lti_1p3, :core, :validation, :outcome], + %{count: 1}, + metadata + ) + + log_event(:outcome, metadata) + :ok + end + + defp log_event(type, metadata) do + level = if metadata.result == :error, do: :warning, else: :info + + Logger.log(level, fn -> + "lti_1p3 core_validation #{type}=#{type} result=#{metadata.result} stage=#{Map.get(metadata, :stage)} reason=#{Map.get(metadata, :reason)}" + end) + end +end diff --git a/lib/lti_1p3/core/validation/deployment.ex b/lib/lti_1p3/core/validation/deployment.ex new file mode 100644 index 0000000..8dbb956 --- /dev/null +++ b/lib/lti_1p3/core/validation/deployment.ex @@ -0,0 +1,29 @@ +defmodule Lti_1p3.Core.Validation.Deployment do + @moduledoc """ + Deployment validation for launch claims. + """ + + import Lti_1p3.Config + + alias Lti_1p3.Core.Errors + + @deployment_claim "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + + @spec validate(map(), map()) :: {:ok, String.t()} | {:error, Errors.t()} + def validate(registration, claims) do + deployment_id = Map.get(claims, @deployment_claim) + + case provider!().get_deployment(registration, deployment_id) do + nil -> + Errors.error( + :deployment, + :invalid_deployment, + "Deployment with id \"#{deployment_id}\" not found", + %{registration_id: registration.id, deployment_id: deployment_id} + ) + + _deployment -> + {:ok, deployment_id} + end + end +end diff --git a/lib/lti_1p3/core/validation/jwt.ex b/lib/lti_1p3/core/validation/jwt.ex new file mode 100644 index 0000000..e8c9471 --- /dev/null +++ b/lib/lti_1p3/core/validation/jwt.ex @@ -0,0 +1,116 @@ +defmodule Lti_1p3.Core.Validation.Jwt do + @moduledoc """ + JWT header, algorithm, key resolution, signature, issuer, and audience validation. + """ + + import Lti_1p3.Config + + alias Lti_1p3.Core.Errors + + @allowed_algs ["RS256"] + + @spec validate(String.t(), map(), keyword()) :: {:ok, map()} | {:error, Errors.t()} + def validate(id_token, registration, opts \\ []) do + allowed_algs = Keyword.get(opts, :allowed_algs, @allowed_algs) + + with {:ok, header} <- peek_header(id_token), + :ok <- validate_algorithm(header, allowed_algs), + {:ok, kid} <- extract_kid(header), + {:ok, public_key} <- fetch_public_key(registration.key_set_url, kid), + {:ok, claims} <- verify_signature(id_token, header["alg"], public_key), + :ok <- validate_issuer(claims, registration.issuer), + :ok <- validate_audience(claims, registration.client_id) do + {:ok, claims} + end + end + + defp peek_header(jwt) do + case Joken.peek_header(jwt) do + {:ok, header} -> {:ok, header} + {:error, reason} -> Errors.error(:jwt, :token_malformed, "Invalid JWT", %{reason: reason}) + end + end + + defp validate_algorithm(%{"alg" => alg}, allowed_algs) do + if alg in allowed_algs do + :ok + else + Errors.error(:jwt, :invalid_jwt_alg, "Unsupported JWT alg #{inspect(alg)}") + end + end + + defp validate_algorithm(_header, _allowed_algs) do + Errors.error(:jwt, :missing_jwt_alg, "JWT header is missing alg") + end + + defp extract_kid(%{"kid" => kid}) when is_binary(kid) and kid != "", do: {:ok, kid} + defp extract_kid(_header), do: Errors.error(:jwt, :missing_kid, "JWT header is missing kid") + + defp fetch_public_key(key_set_url, kid) do + case key_provider!().get_public_key(key_set_url, kid) do + {:ok, key} -> + {:ok, key} + + {:error, %{reason: reason, msg: msg}} -> + Errors.error(:jwt, reason, msg) + + {:error, reason} -> + Errors.error(:jwt, :key_resolution_failed, "Failed to resolve key", %{reason: reason}) + end + end + + defp verify_signature(id_token, alg, public_key) do + {_kty, key_map} = JOSE.JWK.to_map(public_key) + signer = Joken.Signer.create(alg, key_map) + + case Joken.verify_and_validate(%{}, id_token, signer) do + {:ok, claims} -> {:ok, claims} + {:error, reason} -> Errors.error(:jwt, :signature_error, "Invalid JWT", %{reason: reason}) + end + end + + defp validate_issuer(%{"iss" => issuer}, issuer), do: :ok + + defp validate_issuer(_claims, _issuer) do + Errors.error( + :jwt, + :invalid_issuer, + "Issuer ('iss' claim) in JWT doesn't match the expected issuer" + ) + end + + defp validate_audience(%{"aud" => audience}, expected_client_id) when is_binary(audience) do + if audience == expected_client_id do + :ok + else + Errors.error( + :jwt, + :invalid_audience, + "Audience ('aud' claim) in JWT doesn't contain the expected audience" + ) + end + end + + defp validate_audience(%{"aud" => audiences} = claims, expected_client_id) + when is_list(audiences) do + cond do + length(audiences) == 1 and expected_client_id in audiences -> + :ok + + length(audiences) > 1 and expected_client_id in audiences and + Map.get(claims, "azp") == expected_client_id -> + :ok + + true -> + Errors.error( + :jwt, + :invalid_audience, + "Audience ('aud' claim) in JWT doesn't contain the expected audience" + ) + end + end + + defp validate_audience(_claims, _expected_client_id) do + Errors.error(:jwt, :invalid_audience, "Audience ('aud' claim) in JWT is malformed") + end +end diff --git a/lib/lti_1p3/core/validation/message.ex b/lib/lti_1p3/core/validation/message.ex new file mode 100644 index 0000000..c7b9c89 --- /dev/null +++ b/lib/lti_1p3/core/validation/message.ex @@ -0,0 +1,37 @@ +defmodule Lti_1p3.Core.Validation.Message do + @moduledoc """ + LTI message type dispatch and validation. + """ + + alias Lti_1p3.Core.Errors + alias Lti_1p3.Tool.MessageDispatch + + @message_type_claim "https://purl.imsglobal.org/spec/lti/claim/message_type" + + @spec validate(map()) :: {:ok, String.t()} | {:error, Errors.t()} + def validate(claims) do + case Map.get(claims, @message_type_claim) do + nil -> + Errors.error(:message, :invalid_message_type, "Missing message type") + + message_type -> + case MessageDispatch.validate(claims) do + :ok -> + {:ok, message_type} + + {:error, %{reason: reason, msg: msg, details: details}} -> + Errors.error(:message, reason, msg, Map.put(details, :message_type, message_type)) + + {:error, reason, msg} -> + Errors.error(:message, reason, msg, %{message_type: message_type}) + + :unsupported -> + Errors.error( + :message, + :invalid_message_type, + "Invalid or unsupported message type \"#{message_type}\"" + ) + end + end + end +end diff --git a/lib/lti_1p3/core/validation/nonce.ex b/lib/lti_1p3/core/validation/nonce.ex new file mode 100644 index 0000000..c6a80b1 --- /dev/null +++ b/lib/lti_1p3/core/validation/nonce.ex @@ -0,0 +1,21 @@ +defmodule Lti_1p3.Core.Validation.Nonce do + @moduledoc """ + Nonce uniqueness validation. + """ + + alias Lti_1p3.Core.Errors + + @spec validate(map(), String.t()) :: :ok | {:error, Errors.t()} + def validate(claims, domain) do + case Lti_1p3.Nonces.create_nonce(claims["nonce"], domain) do + {:ok, _nonce} -> + :ok + + {:error, %Lti_1p3.DataProviderError{reason: :unique_constraint_violation}} -> + Errors.error(:nonce, :invalid_nonce, "Duplicate nonce") + + {:error, %Lti_1p3.DataProviderError{msg: msg}} -> + Errors.error(:nonce, :invalid_nonce, msg) + end + end +end diff --git a/lib/lti_1p3/core/validation/registration.ex b/lib/lti_1p3/core/validation/registration.ex new file mode 100644 index 0000000..a266e6a --- /dev/null +++ b/lib/lti_1p3/core/validation/registration.ex @@ -0,0 +1,69 @@ +defmodule Lti_1p3.Core.Validation.Registration do + @moduledoc """ + Registration resolution from launch claims. + """ + + import Lti_1p3.Config + + alias Lti_1p3.Core.Errors + + @spec resolve(map()) :: {:ok, map(), map()} | {:error, Errors.t()} + def resolve(params) do + with {:ok, id_token} <- fetch_param(params, "id_token"), + {:ok, claims} <- peek_claims(id_token), + {:ok, issuer} <- required_claim(claims, "iss", :issuer), + {:ok, client_id} <- audience_to_client_id(claims), + {:ok, registration} <- lookup_registration(issuer, client_id) do + {:ok, registration, claims} + end + end + + defp fetch_param(params, name) do + case Map.get(params, name) do + nil -> Errors.error(:registration, :missing_param, "Missing #{name}") + value -> {:ok, value} + end + end + + defp peek_claims(jwt) do + case Joken.peek_claims(jwt) do + {:ok, claims} -> + {:ok, claims} + + {:error, reason} -> + Errors.error(:registration, :token_malformed, "Invalid JWT", %{reason: reason}) + end + end + + defp required_claim(claims, key, name) do + case Map.get(claims, key) do + nil -> Errors.error(:registration, :invalid_registration, "Missing #{name} claim") + value -> {:ok, value} + end + end + + defp audience_to_client_id(%{"aud" => [client_id | _]}) when is_binary(client_id), + do: {:ok, client_id} + + defp audience_to_client_id(%{"aud" => client_id}) when is_binary(client_id), + do: {:ok, client_id} + + defp audience_to_client_id(_claims) do + Errors.error(:registration, :invalid_registration, "Missing or invalid aud claim") + end + + defp lookup_registration(issuer, client_id) do + case provider!().get_registration_by_issuer_client_id(issuer, client_id) do + nil -> + Errors.error( + :registration, + :invalid_registration, + "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found", + %{issuer: issuer, client_id: client_id} + ) + + registration -> + {:ok, registration} + end + end +end diff --git a/lib/lti_1p3/core/validation/state.ex b/lib/lti_1p3/core/validation/state.ex new file mode 100644 index 0000000..5ec4264 --- /dev/null +++ b/lib/lti_1p3/core/validation/state.ex @@ -0,0 +1,26 @@ +defmodule Lti_1p3.Core.Validation.State do + @moduledoc """ + OIDC state validation. + """ + + alias Lti_1p3.Core.Errors + + @spec validate(String.t() | nil, String.t() | nil) :: :ok | {:error, Errors.t()} + def validate(nil, _request_state) do + Errors.error( + :state, + :invalid_oidc_state, + "State from session is missing. Make sure cookies are enabled and configured correctly" + ) + end + + def validate(_session_state, nil) do + Errors.error(:state, :invalid_oidc_state, "State from OIDC request is missing") + end + + def validate(session_state, request_state) when session_state == request_state, do: :ok + + def validate(_session_state, _request_state) do + Errors.error(:state, :invalid_oidc_state, "State from OIDC request does not match session") + end +end diff --git a/lib/lti_1p3/core/validation/timestamps.ex b/lib/lti_1p3/core/validation/timestamps.ex new file mode 100644 index 0000000..89ab2b1 --- /dev/null +++ b/lib/lti_1p3/core/validation/timestamps.ex @@ -0,0 +1,41 @@ +defmodule Lti_1p3.Core.Validation.Timestamps do + @moduledoc """ + JWT exp/iat validation with skew tolerance. + """ + + alias Lti_1p3.Core.Errors + + @spec validate(map(), keyword()) :: :ok | {:error, Errors.t()} + def validate(claims, opts \\ []) do + skew_seconds = Keyword.get(opts, :clock_skew_seconds, 5) + + with {:ok, exp} <- unix_claim(claims, "exp"), + {:ok, iat} <- unix_claim(claims, "iat") do + now = DateTime.utc_now() |> DateTime.to_unix() + + expired? = exp < now - skew_seconds + iat_in_future? = iat > now + skew_seconds + + case {expired?, iat_in_future?} do + {false, false} -> + :ok + + {true, false} -> + Errors.error(:timestamps, :invalid_jwt_timestamp, "JWT exp is expired") + + {false, true} -> + Errors.error(:timestamps, :invalid_jwt_timestamp, "JWT iat is invalid") + + {true, true} -> + Errors.error(:timestamps, :invalid_jwt_timestamp, "JWT exp and iat are invalid") + end + end + end + + defp unix_claim(claims, key) do + case Map.get(claims, key) do + value when is_integer(value) -> {:ok, value} + _ -> Errors.error(:timestamps, :invalid_jwt_timestamp, "Timestamps are invalid") + end + end +end diff --git a/lib/lti_1p3/data_provider.ex b/lib/lti_1p3/data_provider.ex index 1001b9a..04a2cad 100644 --- a/lib/lti_1p3/data_provider.ex +++ b/lib/lti_1p3/data_provider.ex @@ -96,7 +96,7 @@ defmodule Lti_1p3.ToolDataProvider do {nil, nil} """ @callback get_registration_deployment(String.t(), String.t(), String.t()) :: - {%Registration{}, %Deployment{}} | nil + {%Registration{} | nil, %Deployment{} | nil} @doc """ Gets the jwk associated with the given Registration. diff --git a/lib/lti_1p3/data_providers/memory_provider.ex b/lib/lti_1p3/data_providers/memory_provider.ex index ba00fa2..0c70fb6 100644 --- a/lib/lti_1p3/data_providers/memory_provider.ex +++ b/lib/lti_1p3/data_providers/memory_provider.ex @@ -1,90 +1,75 @@ defmodule Lti_1p3.DataProviders.MemoryProvider do - use GenServer + @moduledoc """ + Reference in-memory provider implementation backed by an `Agent`. + """ alias Lti_1p3.DataProvider - alias Lti_1p3.PlatformDataProvider - alias Lti_1p3.ToolDataProvider alias Lti_1p3.DataProviderError alias Lti_1p3.Jwk alias Lti_1p3.Nonce - alias Lti_1p3.Tool.Registration - alias Lti_1p3.Tool.Deployment - alias Lti_1p3.Platform.PlatformInstance alias Lti_1p3.Platform.LoginHint + alias Lti_1p3.Platform.PlatformInstance + alias Lti_1p3.PlatformDataProvider + alias Lti_1p3.Tool.Deployment + alias Lti_1p3.Tool.Registration + alias Lti_1p3.ToolDataProvider - @impl GenServer + @behaviour DataProvider + @behaviour ToolDataProvider + @behaviour PlatformDataProvider + + @spec init(any()) :: {:ok, map()} def init(_opts \\ []) do - initial_state = %{ + {:ok, initial_state()} + end + + @spec initial_state() :: map() + def initial_state do + %{ index_counters: %{}, jwks: [], nonces: %{}, registrations: %{}, deployments: [], platform_instances: %{}, - login_hints: %{}, + login_hints: %{} } - - {:ok, initial_state} end + @spec start_link(map()) :: Agent.on_start() def start_link(initial_state) do Agent.start_link(fn -> initial_state end, name: __MODULE__) end - defp get_next_index(type) do - next_index = Agent.get(__MODULE__, fn state -> - state.index_counters - |> Map.get(type, 0) - end) - - Agent.update(__MODULE__, fn state -> - %{state | index_counters: state.index_counters |> Map.put(type, next_index + 1)} - end) - - next_index - end - - ## DataProviders ## - @behaviour DataProvider - @impl DataProvider def create_jwk(%Jwk{} = jwk) do - jwk = jwk |> Map.put(:id, get_next_index(:jwk)) - Agent.update(__MODULE__, fn state -> - %{state | jwks: state.jwks ++ [jwk]} - end) - + jwk = Map.put(jwk, :id, get_next_index(:jwk)) + Agent.update(__MODULE__, fn state -> %{state | jwks: state.jwks ++ [jwk]} end) {:ok, jwk} end @impl DataProvider - def get_active_jwk() do - active_jwk = Agent.get(__MODULE__, fn state -> - state - |> Map.get(:jwks) - |> Enum.find(fn jwk -> jwk.active == true end) - end) + def get_active_jwk do + active_jwk = + Agent.get(__MODULE__, fn state -> + Enum.find(state.jwks, &(&1.active == true)) + end) case active_jwk do - nil -> - {:error, %DataProviderError{msg: "No active Jwk", reason: :not_found}} - - active_jwk -> - {:ok, active_jwk} + nil -> {:error, %DataProviderError{msg: "No active Jwk", reason: :not_found}} + _ -> {:ok, active_jwk} end end @impl DataProvider - def get_all_jwks() do - Agent.get(__MODULE__, fn state -> - state - |> Map.get(:jwks) - end) + def get_all_jwks do + Agent.get(__MODULE__, & &1.jwks) end @impl DataProvider def create_nonce(%Nonce{} = nonce) do - nonce = nonce + nonce = + nonce |> Map.from_struct() |> Map.put(:inserted_at, Timex.now()) |> Map.put(:id, get_next_index(:nonce)) @@ -92,67 +77,54 @@ defmodule Lti_1p3.DataProviders.MemoryProvider do case get_nonce(nonce.value, nonce.domain) do nil -> Agent.update(__MODULE__, fn state -> - %{state | nonces: state.nonces |> Map.put_new(nonce_key(nonce), nonce)} + %{state | nonces: Map.put_new(state.nonces, nonce_key(nonce), nonce)} end) {:ok, struct(Nonce, nonce)} + _ -> - {:error, %Lti_1p3.DataProviderError{msg: "Nonce with value already exists", reason: :unique_constraint_violation}} + {:error, + %DataProviderError{ + msg: "Nonce with value already exists", + reason: :unique_constraint_violation + }} end end @impl DataProvider def get_nonce(value, domain \\ nil) do Agent.get(__MODULE__, fn state -> - state - |> Map.get(:nonces) - |> Map.get(nonce_key(%{value: value, domain: domain})) - |> case do - nil -> - nil - nonce -> - struct(Nonce, nonce) + case Map.get(state.nonces, nonce_key(%{value: value, domain: domain})) do + nil -> nil + nonce -> struct(Nonce, nonce) end end) end - def nonce_key(%{value: value, domain: domain}) do - case domain do - nil -> - value - domain -> - value <> domain - end - end - - # 86400 seconds = 24 hours @impl DataProvider def delete_expired_nonces(nonce_ttl_sec \\ 86_400) do - nonce_expiry = Timex.now |> Timex.subtract(Timex.Duration.from_seconds(nonce_ttl_sec)) + nonce_expiry = Timex.now() |> Timex.subtract(Timex.Duration.from_seconds(nonce_ttl_sec)) Agent.update(__MODULE__, fn state -> - %{state | nonces: state.nonces - |> Enum.reduce(%{}, fn {key, nonce}, acc -> - if nonce.inserted_at > nonce_expiry do - Map.put(acc, key, nonce) - else - acc - end + filtered_nonces = + Enum.reduce(state.nonces, %{}, fn {key, nonce}, acc -> + if nonce.inserted_at > nonce_expiry, do: Map.put(acc, key, nonce), else: acc end) - } + + %{state | nonces: filtered_nonces} end) end - ## ToolDataProviders ## - @behaviour ToolDataProvider - @impl ToolDataProvider def create_registration(%Registration{issuer: issuer, client_id: client_id} = registration) do - registration = registration - |> Map.put(:id, get_next_index(:registration)) + registration = Map.put(registration, :id, get_next_index(:registration)) Agent.update(__MODULE__, fn state -> - %{state | registrations: state.registrations |> Map.put(registration_key(issuer, client_id), registration)} + %{ + state + | registrations: + Map.put(state.registrations, registration_key(issuer, client_id), registration) + } end) {:ok, registration} @@ -160,8 +132,7 @@ defmodule Lti_1p3.DataProviders.MemoryProvider do @impl ToolDataProvider def create_deployment(%Deployment{} = deployment) do - deployment = deployment - |> Map.put(:id, get_next_index(:deployment)) + deployment = Map.put(deployment, :id, get_next_index(:deployment)) Agent.update(__MODULE__, fn state -> %{state | deployments: state.deployments ++ [deployment]} @@ -172,66 +143,59 @@ defmodule Lti_1p3.DataProviders.MemoryProvider do @impl ToolDataProvider def get_registration_deployment(issuer, client_id, deployment_id) do - registration = Agent.get(__MODULE__, fn state -> - state.registrations - |> Enum.find(fn {_k, r} -> r.issuer == issuer && r.client_id == client_id end) - end) + registration = get_registration_by_issuer_client_id(issuer, client_id) case registration do nil -> {nil, nil} - {_issuer, registration} -> - {registration, + registration -> + deployment = Agent.get(__MODULE__, fn state -> - state.deployments - |> Enum.find(fn d -> - d.registration_id == registration.id && d.deployment_id == deployment_id + Enum.find(state.deployments, fn d -> + d.registration_id == registration.id and d.deployment_id == deployment_id end) end) - } + + {registration, deployment} end end @impl ToolDataProvider def get_jwk_by_registration(%Registration{tool_jwk_id: tool_jwk_id}) do - Agent.get(__MODULE__, fn state -> - state.jwks - |> Enum.find(fn jwk -> jwk.id == tool_jwk_id end) - end) + jwk = Agent.get(__MODULE__, fn state -> Enum.find(state.jwks, &(&1.id == tool_jwk_id)) end) + + case jwk do + nil -> {:error, %DataProviderError{msg: "Jwk not found", reason: :not_found}} + _ -> {:ok, jwk} + end end -\ + @impl ToolDataProvider def get_registration_by_issuer_client_id(issuer, client_id) do Agent.get(__MODULE__, fn state -> - state.registrations - |> Map.get(registration_key(issuer, client_id)) + Map.get(state.registrations, registration_key(issuer, client_id)) end) end - defp registration_key(issuer, client_id) do - issuer <> client_id - end - @impl ToolDataProvider def get_deployment(%Registration{id: registration_id}, deployment_id) do Agent.get(__MODULE__, fn state -> - state.deployments - |> Enum.find(fn d -> d.registration_id == registration_id && d.deployment_id == deployment_id end) + Enum.find(state.deployments, fn d -> + d.registration_id == registration_id and d.deployment_id == deployment_id + end) end) end - - ## PlatformDataProviders ## - @behaviour PlatformDataProvider - @impl PlatformDataProvider def create_platform_instance(%PlatformInstance{client_id: client_id} = platform_instance) do - platform_instance = platform_instance - |> Map.put(:id, get_next_index(:platform_instance)) + platform_instance = Map.put(platform_instance, :id, get_next_index(:platform_instance)) Agent.update(__MODULE__, fn state -> - %{state | platform_instances: state.platform_instances |> Map.put_new(client_id, platform_instance)} + %{ + state + | platform_instances: Map.put_new(state.platform_instances, client_id, platform_instance) + } end) {:ok, platform_instance} @@ -239,47 +203,58 @@ defmodule Lti_1p3.DataProviders.MemoryProvider do @impl PlatformDataProvider def get_platform_instance_by_client_id(client_id) do - Agent.get(__MODULE__, fn state -> - state.platform_instances - |> Map.get(client_id) - end) + Agent.get(__MODULE__, fn state -> Map.get(state.platform_instances, client_id) end) end @impl PlatformDataProvider def get_login_hint_by_value(value) do - Agent.get(__MODULE__, fn state -> - state.login_hints - |> Map.get(value) - end) + Agent.get(__MODULE__, fn state -> Map.get(state.login_hints, value) end) end @impl PlatformDataProvider def create_login_hint(%LoginHint{value: value} = login_hint) do - login_hint = login_hint + login_hint = + login_hint + |> Map.put(:inserted_at, Timex.now()) |> Map.put(:id, get_next_index(:login_hint)) Agent.update(__MODULE__, fn state -> - %{state | login_hints: state.login_hints |> Map.put_new(value, login_hint)} + %{state | login_hints: Map.put_new(state.login_hints, value, login_hint)} end) {:ok, login_hint} end - # 86400 seconds = 24 hours @impl PlatformDataProvider def delete_expired_login_hints(login_hint_ttl_sec \\ 86_400) do - login_hint_expiry = Timex.now |> Timex.subtract(Timex.Duration.from_seconds(login_hint_ttl_sec)) + login_hint_expiry = + Timex.now() |> Timex.subtract(Timex.Duration.from_seconds(login_hint_ttl_sec)) Agent.update(__MODULE__, fn state -> - %{state | login_hints: state.login_hints - |> Enum.reduce(%{}, fn {key, login_hint}, acc -> - if login_hint.inserted_at > login_hint_expiry do - Map.put(acc, key, login_hint) - else - acc - end + filtered_hints = + Enum.reduce(state.login_hints, %{}, fn {key, login_hint}, acc -> + if login_hint.inserted_at > login_hint_expiry, + do: Map.put(acc, key, login_hint), + else: acc end) - } + + %{state | login_hints: filtered_hints} end) end + + @doc false + def nonce_key(%{value: value, domain: nil}), do: value + def nonce_key(%{value: value, domain: domain}), do: value <> domain + + defp registration_key(issuer, client_id), do: issuer <> client_id + + defp get_next_index(type) do + next_index = Agent.get(__MODULE__, fn state -> Map.get(state.index_counters, type, 0) end) + + Agent.update(__MODULE__, fn state -> + %{state | index_counters: Map.put(state.index_counters, type, next_index + 1)} + end) + + next_index + end end diff --git a/lib/lti_1p3/deep_linking/claim_keys.ex b/lib/lti_1p3/deep_linking/claim_keys.ex new file mode 100644 index 0000000..f29b765 --- /dev/null +++ b/lib/lti_1p3/deep_linking/claim_keys.ex @@ -0,0 +1,28 @@ +defmodule Lti_1p3.DeepLinking.ClaimKeys do + @moduledoc """ + Shared deep-linking claim key helpers. + """ + + @message_type "https://purl.imsglobal.org/spec/lti/claim/message_type" + @version "https://purl.imsglobal.org/spec/lti/claim/version" + @roles "https://purl.imsglobal.org/spec/lti/claim/roles" + @deep_linking_settings "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + @content_items "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" + @data "https://purl.imsglobal.org/spec/lti-dl/claim/data" + + @type key_name :: + :message_type + | :version + | :roles + | :deep_linking_settings + | :content_items + | :data + + @spec key(key_name()) :: String.t() + def key(:message_type), do: @message_type + def key(:version), do: @version + def key(:roles), do: @roles + def key(:deep_linking_settings), do: @deep_linking_settings + def key(:content_items), do: @content_items + def key(:data), do: @data +end diff --git a/lib/lti_1p3/jwk.ex b/lib/lti_1p3/jwk.ex index 1587330..6766506 100644 --- a/lib/lti_1p3/jwk.ex +++ b/lib/lti_1p3/jwk.ex @@ -3,12 +3,11 @@ defmodule Lti_1p3.Jwk do defstruct [:id, :pem, :typ, :alg, :kid, :active] @type t() :: %__MODULE__{ - id: integer(), - pem: String.t(), - typ: String.t(), - alg: String.t(), - kid: String.t(), - active: boolean() - } - + id: integer(), + pem: String.t(), + typ: String.t(), + alg: String.t(), + kid: String.t(), + active: boolean() + } end diff --git a/lib/lti_1p3/key_generator.ex b/lib/lti_1p3/key_generator.ex index dd52ad9..e909bd4 100644 --- a/lib/lti_1p3/key_generator.ex +++ b/lib/lti_1p3/key_generator.ex @@ -1,13 +1,13 @@ defmodule Lti_1p3.KeyGenerator do - - @chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" |> String.split("", trim: true) + @chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + |> String.split("", trim: true) @doc """ Create a random passphrase of size given (defaults to 256) """ def passphrase(len \\ 256) do - Enum.map((1..len), fn _i -> Enum.random(@chars) end) - |> Enum.join("") + Enum.map(1..len, fn _i -> Enum.random(@chars) end) + |> Enum.join("") end @doc """ diff --git a/lib/lti_1p3/nonce.ex b/lib/lti_1p3/nonce.ex index c92da99..8a0e8c0 100644 --- a/lib/lti_1p3/nonce.ex +++ b/lib/lti_1p3/nonce.ex @@ -3,9 +3,8 @@ defmodule Lti_1p3.Nonce do defstruct [:id, :value, :domain] @type t() :: %__MODULE__{ - id: integer(), - value: String.t(), - domain: String.t(), - } - + id: integer(), + value: String.t(), + domain: String.t() + } end diff --git a/lib/lti_1p3/nonces.ex b/lib/lti_1p3/nonces.ex index f665af8..e790cb9 100644 --- a/lib/lti_1p3/nonces.ex +++ b/lib/lti_1p3/nonces.ex @@ -24,7 +24,8 @@ defmodule Lti_1p3.Nonces do iex> create_nonce("value", "domain") {:error, %Lti_1p3.DataProviderError{}} """ - def create_nonce(value, domain \\ nil), do: provider!().create_nonce(%Nonce{value: value, domain: domain}) + def create_nonce(value, domain \\ nil), + do: provider!().create_nonce(%Nonce{value: value, domain: domain}) @doc """ Removes all nonces older than the configured @max_nonce_ttl_sec value @@ -37,5 +38,4 @@ defmodule Lti_1p3.Nonces do Logger.info("Nonce cleanup complete.") end - end diff --git a/lib/lti_1p3/platform.ex b/lib/lti_1p3/platform.ex index ae0f314..fedf34a 100644 --- a/lib/lti_1p3/platform.ex +++ b/lib/lti_1p3/platform.ex @@ -1,14 +1,41 @@ defmodule Lti_1p3.Platform do + @moduledoc """ + Public API for platform-side LTI 1.3 flows and platform registrations. + """ + import Lti_1p3.Config + alias Lti_1p3.Platform.AuthorizationPayload + alias Lti_1p3.Platform.AuthorizationRedirect + + @type error_map :: %{reason: atom(), stage: atom(), msg: String.t(), details: map()} + @doc """ Creates a new platform instance. - ## Examples - iex> create_platform_instance(platform_instance) - {:ok, %Lti_1p3.Platform.PlatformInstance{}} - iex> create_platform_instance(platform_instance) - {:error, %Lti_1p3.DataProviderError{}} """ - def create_platform_instance(%Lti_1p3.Platform.PlatformInstance{} = platform_instance), do: - provider!().create_platform_instance(platform_instance) + @spec create_platform_instance(Lti_1p3.Platform.PlatformInstance.t()) :: + {:ok, Lti_1p3.Platform.PlatformInstance.t()} | {:error, Lti_1p3.DataProviderError.t()} + def create_platform_instance(%Lti_1p3.Platform.PlatformInstance{} = platform_instance), + do: provider!().create_platform_instance(platform_instance) + + @doc """ + Validates and authorizes a platform redirect request, returning a normalized payload. + """ + @spec authorize_redirect(map(), map(), String.t(), list(), keyword()) :: + {:ok, AuthorizationPayload.t()} | {:error, error_map()} + def authorize_redirect(params, current_user, issuer, claims, opts \\ []) do + case AuthorizationRedirect.authorize_redirect(params, current_user, issuer, claims, opts) do + {:ok, redirect_uri, state, id_token} -> + {:ok, %AuthorizationPayload{redirect_uri: redirect_uri, state: state, id_token: id_token}} + + {:error, error} -> + {:error, ensure_error_shape(error, :platform_authorize)} + end + end + + defp ensure_error_shape(error, stage) do + error + |> Map.put_new(:stage, stage) + |> Map.put_new(:details, %{}) + end end diff --git a/lib/lti_1p3/platform/authorization_payload.ex b/lib/lti_1p3/platform/authorization_payload.ex new file mode 100644 index 0000000..2fc85e9 --- /dev/null +++ b/lib/lti_1p3/platform/authorization_payload.ex @@ -0,0 +1,14 @@ +defmodule Lti_1p3.Platform.AuthorizationPayload do + @moduledoc """ + Normalized authorization redirect payload returned by platform APIs. + """ + + @enforce_keys [:redirect_uri, :state, :id_token] + defstruct [:redirect_uri, :state, :id_token] + + @type t() :: %__MODULE__{ + redirect_uri: String.t(), + state: String.t(), + id_token: String.t() + } +end diff --git a/lib/lti_1p3/platform/authorization_redirect.ex b/lib/lti_1p3/platform/authorization_redirect.ex index efc0793..4ef15ab 100644 --- a/lib/lti_1p3/platform/authorization_redirect.ex +++ b/lib/lti_1p3/platform/authorization_redirect.ex @@ -1,23 +1,28 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do - import Lti_1p3.Config - import Lti_1p3.Utils + @moduledoc """ + Platform-side authorization redirect validation and id_token generation. + """ - alias Lti_1p3.Platform.LoginHint - alias Lti_1p3.Platform.LoginHints + import Lti_1p3.Config alias Lti_1p3.Claims.Claim alias Lti_1p3.Claims.{ - Version, - ResourceLink, - DeploymentId, Context, - Roles, + DeploymentId, PlatformInstance, - TargetLinkUri + ResourceLink, + Roles, + TargetLinkUri, + Version } - @type params() :: %{state: binary(), id_token: binary()} + alias Lti_1p3.Core.Telemetry + alias Lti_1p3.Core.Validation.Nonce + alias Lti_1p3.Platform.LoginHint + alias Lti_1p3.Platform.LoginHints + + @type params() :: %{optional(String.t()) => String.t()} @type user() :: %{id: integer()} @type claim() :: @@ -28,90 +33,104 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do | Context.t() | PlatformInstance.t() - @doc """ - Validates an authentication response and returns the state and platform lti params in a signed id_token signed if successful. - """ - @spec authorize_redirect( - params(), - user(), - binary(), - list(claim()) - ) :: - {:ok, binary(), binary(), binary()} - | {:error, %{optional(atom()) => any(), reason: atom(), msg: String.t()}} - def authorize_redirect(params, current_user, issuer, claims) do + @spec authorize_redirect(params(), user(), binary(), list(claim()), keyword()) :: + {:ok, binary(), binary(), binary()} | {:error, map()} + def authorize_redirect(params, current_user, issuer, claims, opts \\ []) do + correlation_id = Keyword.get(opts, :correlation_id, UUID.uuid4()) + + with {:ok, platform_instance} <- resolve_platform_instance(params, correlation_id), + {:ok, valid_redirect_uris} <- + resolve_valid_redirect_uris(platform_instance, correlation_id), + :ok <- validate_oidc_params(params, correlation_id), + :ok <- validate_scope(params, correlation_id), + :ok <- validate_user(params, current_user, correlation_id), + :ok <- validate_client(params, platform_instance.client_id, correlation_id), + :ok <- validate_redirect_uri(params, valid_redirect_uris, correlation_id), + :ok <- validate_nonce(params, correlation_id), + {:ok, active_jwk} <- resolve_active_jwk(correlation_id), + {:ok, id_token} <- + build_id_token_for_redirect( + params, + current_user, + issuer, + claims, + platform_instance.client_id, + active_jwk, + correlation_id + ) do + Telemetry.emit_outcome(:ok, %{ + flow: :platform_authorize_redirect, + correlation_id: correlation_id + }) + + {:ok, params["redirect_uri"], params["state"], id_token} + else + {:error, %{reason: reason, stage: stage}} = result -> + Telemetry.emit_outcome(:error, %{ + flow: :platform_authorize_redirect, + correlation_id: correlation_id, + reason: reason, + stage: stage + }) + + result + end + end + + defp build_id_token(params, current_user, issuer, claims, client_id, active_jwk) do + custom_header = %{"kid" => active_jwk.kid} + signer = Joken.Signer.create("RS256", %{"pem" => active_jwk.pem}, custom_header) + user_details = Map.from_struct(current_user) + + base_claims = + %{} + |> oidc_standard_claims(user_details) + |> oidc_additional_claims(user_details) + |> add_claim(Version.version("1.3.0")) + |> add_claim("nonce", params["nonce"]) + + with {:ok, claims} <- + build_claims_map(base_claims, claims, + required: [ + DeploymentId.key(), + TargetLinkUri.key(), + Roles.key() + ] + ), + {:ok, claims} <- + Joken.Config.default_claims(iss: issuer, aud: client_id) + |> Joken.generate_claims(claims), + {:ok, id_token, _claims} <- Joken.encode_and_sign(claims, signer) do + {:ok, id_token} + else + {:error, %{reason: _reason} = error} -> + {:error, Map.put_new(error, :stage, :token_build)} + + {:error, reason} -> + error(:token_build, :token_build_failed, "Unable to build id_token", %{reason: reason}) + end + end + + defp get_platform_instance(params) do case provider!().get_platform_instance_by_client_id(params["client_id"]) do nil -> - {:error, - %{ - reason: :client_not_registered, - msg: "No platform exists with client id '#{params["client_id"]}'" - }} + error( + :platform_registration, + :client_not_registered, + "No platform exists with client id '#{params["client_id"]}'" + ) platform_instance -> - client_id = platform_instance.client_id - valid_redirect_uris = platform_instance.redirect_uris |> String.split(",") - - # perform authentication response validation per LTI 1.3 specification - # https://www.imsglobal.org/spec/security/v1p0/#step-3-authentication-response - with {:ok} <- validate_oidc_params(params), - {:ok} <- validate_oidc_scope(params), - {:ok} <- validate_current_user(params, current_user), - {:ok} <- validate_client_id(params, client_id), - {:ok} <- validate_redirect_uri(params, valid_redirect_uris), - {:ok} <- validate_nonce(params, "authorize_redirect"), - {:ok, active_jwk} <- provider!().get_active_jwk() do - custom_header = %{"kid" => active_jwk.kid} - signer = Joken.Signer.create("RS256", %{"pem" => active_jwk.pem}, custom_header) - user_details = Map.from_struct(current_user) - - base_claims = - %{} - |> oidc_standard_claims(user_details) - |> oidc_additional_claims(user_details) - |> add_claim(Version.version("1.3.0")) - |> add_claim("nonce", params["nonce"]) - - with {:ok, claims} <- - build_claims_map(base_claims, claims, - required: [ - DeploymentId.key(), - TargetLinkUri.key(), - Roles.key() - ] - ), - {:ok, claims} <- - Joken.Config.default_claims(iss: issuer, aud: client_id) - |> Joken.generate_claims(claims), - {:ok, id_token, _claims} <- Joken.encode_and_sign(claims, signer) do - state = params["state"] - redirect_uri = params["redirect_uri"] - - {:ok, redirect_uri, state, id_token} - else - error -> - error - end - end + {:ok, platform_instance} end end defp oidc_standard_claims(map, user_details) do - [ - :sub, - :given_name, - :family_name, - :name, - :email, - :locale - ] + [:sub, :given_name, :family_name, :name, :email, :locale] |> Enum.reduce(map, fn key, acc -> case Map.get(user_details, key) do - nil -> - acc - - value -> - Map.put(acc, Atom.to_string(key), value) + nil -> acc + value -> Map.put(acc, Atom.to_string(key), value) end end) end @@ -134,11 +153,8 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do ] |> Enum.reduce(map, fn key, acc -> case Map.get(user_details, key) do - nil -> - acc - - value -> - Map.put(acc, Atom.to_string(key), value) + nil -> acc + value -> Map.put(acc, Atom.to_string(key), value) end end) end @@ -150,17 +166,11 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do Map.put(map, key, value) end - defp add_claim(map, key, value) do - Map.put(map, key, value) - end + defp add_claim(map, key, value), do: Map.put(map, key, value) defp scrub_empty_values(%{} = map) do Enum.reduce(map, %{}, fn {key, value}, acc -> - if value != nil do - Map.put(acc, key, value) - else - acc - end + if value != nil, do: Map.put(acc, key, value), else: acc end) end @@ -174,16 +184,16 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do {:ok, claims_map} {_, missing_claims} -> - {:error, - %{ - reason: :missing_required_claims, - msg: "Missing required claims: #{Enum.join(missing_claims, ", ")}", - missing_claims: missing_claims - }} + error( + :claims, + :missing_required_claims, + "Missing required claims: #{Enum.join(missing_claims, ", ")}", + %{missing_claims: missing_claims} + ) end end - defp validate_oidc_params(params) do + defp do_validate_oidc_params(params) do required_param_keys = [ "client_id", "login_hint", @@ -199,62 +209,199 @@ defmodule Lti_1p3.Platform.AuthorizationRedirect do !Map.has_key?(params, required_key) end) do [] -> - {:ok} + :ok missing_params -> - {:error, - %{ - reason: :invalid_oidc_params, - msg: - "Invalid OIDC params. The following parameters are missing: #{Enum.join(missing_params, ", ")}", - missing_params: missing_params - }} + error( + :oidc_params, + :invalid_oidc_params, + "Invalid OIDC params. The following parameters are missing: #{Enum.join(missing_params, ", ")}", + %{missing_params: missing_params} + ) end end - defp validate_oidc_scope(params) do + defp do_validate_scope(params) do if params["scope"] == "openid" do - {:ok} + :ok else - {:error, - %{ - reason: :invalid_oidc_scope, - msg: "Invalid OIDC scope: #{params["scope"]}. Scope must be 'openid'" - }} + error( + :scope, + :invalid_oidc_scope, + "Invalid OIDC scope: #{params["scope"]}. Scope must be 'openid'" + ) end end - defp validate_current_user(params, %{id: user_id}) do + defp do_validate_user(params, %{id: user_id}) do case LoginHints.get_login_hint_by_value(params["login_hint"]) do %LoginHint{session_user_id: ^user_id} -> - {:ok} + :ok _ -> - {:error, - %{ - reason: :invalid_login_hint, - msg: "Login hint must be linked with an active user session" - }} + error(:user, :invalid_login_hint, "Login hint must be linked with an active user session") end end - defp validate_client_id(params, client_id) do + defp do_validate_client(params, client_id) do if params["client_id"] == client_id do - {:ok} + :ok else - {:error, %{reason: :unauthorized_client, msg: "Client not authorized in requested context"}} + error(:client, :unauthorized_client, "Client not authorized in requested context") end end - defp validate_redirect_uri(params, valid_redirect_uris) do + defp do_validate_redirect_uri(params, valid_redirect_uris) do if params["redirect_uri"] in valid_redirect_uris do - {:ok} + :ok else - {:error, - %{ - reason: :unauthorized_redirect_uri, - msg: "Redirect URI not authorized in requested context" - }} + error( + :redirect, + :unauthorized_redirect_uri, + "Redirect URI not authorized in requested context" + ) + end + end + + defp resolve_platform_instance(params, correlation_id) do + case get_platform_instance(params) do + {:ok, platform_instance} -> + emit_stage_ok(:platform_registration, correlation_id) + {:ok, platform_instance} + + {:error, error} -> + emit_stage_error(:platform_registration, error, correlation_id) end end + + defp resolve_valid_redirect_uris(platform_instance, correlation_id) do + emit_stage_ok(:redirect, correlation_id) + {:ok, String.split(platform_instance.redirect_uris, ",", trim: true)} + end + + defp validate_oidc_params(params, correlation_id) do + case do_validate_oidc_params(params) do + :ok -> + emit_stage_ok(:oidc_params, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:oidc_params, error, correlation_id) + end + end + + defp validate_scope(params, correlation_id) do + case do_validate_scope(params) do + :ok -> + emit_stage_ok(:scope, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:scope, error, correlation_id) + end + end + + defp validate_user(params, current_user, correlation_id) do + case do_validate_user(params, current_user) do + :ok -> + emit_stage_ok(:user, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:user, error, correlation_id) + end + end + + defp validate_client(params, client_id, correlation_id) do + case do_validate_client(params, client_id) do + :ok -> + emit_stage_ok(:client, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:client, error, correlation_id) + end + end + + defp validate_redirect_uri(params, valid_redirect_uris, correlation_id) do + case do_validate_redirect_uri(params, valid_redirect_uris) do + :ok -> + emit_stage_ok(:redirect, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:redirect, error, correlation_id) + end + end + + defp validate_nonce(params, correlation_id) do + case Nonce.validate(%{"nonce" => params["nonce"]}, "authorize_redirect") do + :ok -> + emit_stage_ok(:nonce, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:nonce, error, correlation_id) + end + end + + defp resolve_active_jwk(correlation_id) do + case provider!().get_active_jwk() do + {:ok, active_jwk} -> + emit_stage_ok(:signing, correlation_id) + {:ok, active_jwk} + + {:error, reason} when is_map(reason) -> + emit_stage_error(:signing, reason, correlation_id) + end + end + + defp build_id_token_for_redirect( + params, + current_user, + issuer, + claims, + client_id, + active_jwk, + correlation_id + ) do + case build_id_token(params, current_user, issuer, claims, client_id, active_jwk) do + {:ok, id_token} -> + emit_stage_ok(:token_build, correlation_id) + {:ok, id_token} + + {:error, error} -> + emit_stage_error(:token_build, error, correlation_id) + end + end + + defp emit_stage_ok(stage, correlation_id) do + Telemetry.emit_stage(stage, :ok, %{ + flow: :platform_authorize_redirect, + correlation_id: correlation_id + }) + end + + defp emit_stage_error(stage, error, correlation_id) do + error = ensure_error_shape(error, stage) + + Telemetry.emit_stage(stage, :error, %{ + flow: :platform_authorize_redirect, + correlation_id: correlation_id, + reason: error.reason, + stage: error.stage + }) + + {:error, error} + end + + defp ensure_error_shape(error, stage) do + error + |> Map.put_new(:stage, stage) + |> Map.put_new(:details, %{}) + end + + defp error(stage, reason, msg, details \\ %{}) do + {:error, %{reason: reason, stage: stage, msg: msg, details: details}} + end end diff --git a/lib/lti_1p3/platform/platform_instance.ex b/lib/lti_1p3/platform/platform_instance.ex index 96987d0..173f986 100644 --- a/lib/lti_1p3/platform/platform_instance.ex +++ b/lib/lti_1p3/platform/platform_instance.ex @@ -1,17 +1,26 @@ defmodule Lti_1p3.Platform.PlatformInstance do @enforce_keys [:name, :target_link_uri, :client_id, :login_url, :keyset_url, :redirect_uris] - defstruct [:id, :name, :description, :target_link_uri, :client_id, :login_url, :keyset_url, :redirect_uris, :custom_params] + defstruct [ + :id, + :name, + :description, + :target_link_uri, + :client_id, + :login_url, + :keyset_url, + :redirect_uris, + :custom_params + ] @type t() :: %__MODULE__{ - id: integer(), - client_id: String.t(), - custom_params: String.t(), - description: String.t(), - keyset_url: String.t(), - login_url: String.t(), - name: String.t(), - redirect_uris: String.t(), - target_link_uri: String.t(), - } - + id: integer(), + client_id: String.t(), + custom_params: String.t(), + description: String.t(), + keyset_url: String.t(), + login_url: String.t(), + name: String.t(), + redirect_uris: String.t(), + target_link_uri: String.t() + } end diff --git a/lib/lti_1p3/tool.ex b/lib/lti_1p3/tool.ex index f3c6d19..43d1961 100644 --- a/lib/lti_1p3/tool.ex +++ b/lib/lti_1p3/tool.ex @@ -1,47 +1,106 @@ defmodule Lti_1p3.Tool do + @moduledoc """ + Public API for tool-side LTI 1.3 flows and configuration resources. + """ + import Lti_1p3.Config + alias Lti_1p3.Tool.Launch + alias Lti_1p3.Tool.DeepLinking + alias Lti_1p3.Tool.LaunchValidation + alias Lti_1p3.Tool.OidcLogin + + @type error_map :: %{reason: atom(), stage: atom(), msg: String.t(), details: map()} + + @doc """ + Validates the incoming OIDC login request and returns the redirect payload. + + Returns `{:ok, %{state: state, redirect_url: redirect_url}}` on success. + """ + @spec login_redirect(map(), keyword()) :: + {:ok, %{state: String.t(), redirect_url: String.t()}} | {:error, error_map()} + def login_redirect(params, opts \\ []) do + case OidcLogin.oidc_login_redirect_url(params, opts) do + {:ok, state, redirect_url} -> {:ok, %{state: state, redirect_url: redirect_url}} + {:error, error} -> {:error, ensure_error_shape(error, :login)} + end + end + + @doc """ + Validates an incoming LTI launch payload and returns a normalized launch struct. + """ + @spec validate_launch(map(), String.t() | nil, keyword()) :: + {:ok, Launch.t()} | {:error, error_map()} + def validate_launch(params, expected_state, opts \\ []) do + LaunchValidation.validate(params, expected_state, opts) + end + + @doc """ + Validates deep-linking launch claims and returns a typed deep-linking request. + """ + @spec validate_deep_linking_request(map()) :: + {:ok, DeepLinking.Request.t()} | {:error, error_map()} + def validate_deep_linking_request(launch_claims) do + DeepLinking.validate_request(launch_claims) + end + + @doc """ + Builds a typed deep-linking content item. + """ + @spec deep_linking_content_item(String.t() | atom(), map()) :: + {:ok, DeepLinking.ContentItem.t()} | {:error, error_map()} + def deep_linking_content_item(type, attrs \\ %{}) do + DeepLinking.content_item(type, attrs) + end + + @doc """ + Builds and signs a deep-linking response JWT. + """ + @spec build_deep_linking_response( + DeepLinking.Request.t(), + [DeepLinking.ContentItem.t()], + keyword() + ) :: + {:ok, %{jwt: String.t(), return_url: String.t()}} | {:error, error_map()} + def build_deep_linking_response(request, items, opts \\ []) do + DeepLinking.build_response(request, items, opts) + end + @doc """ Creates a new deployment. - ## Examples - iex> create_deployment(deployment) - {:ok, %Lti_1p3.Tool.Deployment{}} - iex> create_deployment(deployment) - {:error, %Lti_1p3.DataProviderError{}} """ + @spec create_deployment(Lti_1p3.Tool.Deployment.t()) :: + {:ok, Lti_1p3.Tool.Deployment.t()} | {:error, Lti_1p3.DataProviderError.t()} def create_deployment(%Lti_1p3.Tool.Deployment{} = deployment), do: provider!().create_deployment(deployment) @doc """ Creates a new registration. - ## Examples - iex> create_registration(registration) - {:ok, %Lti_1p3.Tool.Registration{}} - iex> create_registration(registration) - {:error, %Lti_1p3.DataProviderError{}} """ + @spec create_registration(Lti_1p3.Tool.Registration.t()) :: + {:ok, Lti_1p3.Tool.Registration.t()} | {:error, Lti_1p3.DataProviderError.t()} def create_registration(%Lti_1p3.Tool.Registration{} = registration), do: provider!().create_registration(registration) @doc """ - Gets the registration with the given issuer and client_id. - ## Examples - iex> get_registration_by_issuer_client_id(issuer, client_id) - %Registration{} - iex> get_registration_by_issuer_client_id(issuer, client_id) - nil + Gets the registration associated with issuer and client_id. """ + @spec get_registration_by_issuer_client_id(String.t(), String.t()) :: + Lti_1p3.Tool.Registration.t() | nil def get_registration_by_issuer_client_id(issuer, client_id), do: provider!().get_registration_by_issuer_client_id(issuer, client_id) @doc """ - Gets the registration and deployment associated with the given issuer, client_id and deployment_id. - ## Examples - iex> get_registration_deployment(issuer, client_id, deployment_id) - {%Registration{}, %Deployment{}} - iex> get_registration_deployment(issuer, client_id, deployment_id) - {nil, nil} + Gets the registration and deployment associated with issuer, client_id, and deployment_id. """ + @spec get_registration_deployment(String.t(), String.t(), String.t()) :: + {Lti_1p3.Tool.Registration.t() | nil, Lti_1p3.Tool.Deployment.t() | nil} def get_registration_deployment(issuer, client_id, deployment_id), do: provider!().get_registration_deployment(issuer, client_id, deployment_id) + + defp ensure_error_shape(error, stage) do + error + |> Map.put_new(:stage, stage) + |> Map.put_new(:details, %{}) + end end diff --git a/lib/lti_1p3/tool/deep_linking.ex b/lib/lti_1p3/tool/deep_linking.ex new file mode 100644 index 0000000..cf15ab9 --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking.ex @@ -0,0 +1,45 @@ +defmodule Lti_1p3.Tool.DeepLinking do + @moduledoc """ + Public tool deep-linking API for request validation, content item building, + and deep-linking response generation. + """ + + alias Lti_1p3.Tool.DeepLinking.ContentItem + alias Lti_1p3.Tool.DeepLinking.Errors + alias Lti_1p3.Tool.DeepLinking.Request + alias Lti_1p3.Tool.DeepLinking.RequestValidator + alias Lti_1p3.Tool.DeepLinking.ResponseBuilder + alias Lti_1p3.Tool.DeepLinking.Telemetry + + @type error_map :: Errors.t() + + @spec validate_request(map()) :: {:ok, Request.t()} | {:error, error_map()} + def validate_request(claims) do + case RequestValidator.validate_request(claims) do + {:ok, request} = result -> + Telemetry.emit_request(:ok, %{ + issuer: request.issuer, + client_id: normalize_client_id(request.audience) + }) + + result + + {:error, %{reason: reason}} = result -> + Telemetry.emit_request(:error, %{reason: reason}) + result + end + end + + @spec content_item(String.t() | atom(), map()) :: {:ok, ContentItem.t()} | {:error, error_map()} + def content_item(type, attrs \\ %{}), do: ContentItem.content_item(type, attrs) + + @spec build_response(Request.t(), [ContentItem.t()], keyword()) :: + {:ok, %{jwt: String.t(), return_url: String.t()}} | {:error, error_map()} + def build_response(%Request{} = request, items, opts \\ []) do + ResponseBuilder.build_response(request, items, opts) + end + + defp normalize_client_id(aud) when is_binary(aud), do: aud + defp normalize_client_id([head | _tail]) when is_binary(head), do: head + defp normalize_client_id(_), do: nil +end diff --git a/lib/lti_1p3/tool/deep_linking/compatibility_policy.ex b/lib/lti_1p3/tool/deep_linking/compatibility_policy.ex new file mode 100644 index 0000000..85bc8d6 --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/compatibility_policy.ex @@ -0,0 +1,56 @@ +defmodule Lti_1p3.Tool.DeepLinking.CompatibilityPolicy do + @moduledoc """ + Compatibility hooks for LMS variance handling of content item types. + """ + + alias Lti_1p3.Tool.DeepLinking.Errors + + @type strategy :: :strict | :filter_unsupported + + @spec apply([map()], [String.t()], keyword()) :: {:ok, [map()]} | {:error, Errors.t()} + def apply(items, accepted_types, opts \\ []) when is_list(items) and is_list(accepted_types) do + strategy = Keyword.get(opts, :unsupported_type_strategy, :strict) + + accepted_set = MapSet.new(accepted_types) + + Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} -> + type = Map.get(item, "type") + + if MapSet.member?(accepted_set, type) do + {:cont, {:ok, [item | acc]}} + else + case strategy do + :strict -> + {:halt, + Errors.error( + :response, + :unsupported_content_item_type, + "Content item type #{type} is not accepted by launch settings", + %{type: type, accepted_types: accepted_types} + )} + + :filter_unsupported -> + {:cont, {:ok, acc}} + end + end + end) + |> case do + {:ok, filtered_items} -> + filtered_items = Enum.reverse(filtered_items) + + if filtered_items == [] do + Errors.error( + :response, + :no_supported_content_items, + "No supported content items remain after compatibility filtering", + %{accepted_types: accepted_types} + ) + else + {:ok, filtered_items} + end + + {:error, _} = error -> + error + end + end +end diff --git a/lib/lti_1p3/tool/deep_linking/content_item.ex b/lib/lti_1p3/tool/deep_linking/content_item.ex new file mode 100644 index 0000000..f3ecd4c --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/content_item.ex @@ -0,0 +1,178 @@ +defmodule Lti_1p3.Tool.DeepLinking.ContentItem do + @moduledoc """ + Typed deep-linking content item builders with subtype validation. + """ + + alias Lti_1p3.Tool.DeepLinking.Errors + + @enforce_keys [:type] + defstruct [ + :type, + :url, + :title, + :text, + :html, + :icon, + :thumbnail, + :line_item, + :iframe, + :available, + :custom, + :embed, + :window + ] + + @type t() :: %__MODULE__{ + type: String.t(), + url: String.t() | nil, + title: String.t() | nil, + text: String.t() | nil, + html: String.t() | nil, + icon: map() | nil, + thumbnail: map() | nil, + line_item: map() | nil, + iframe: map() | nil, + available: map() | nil, + custom: map() | nil, + embed: map() | nil, + window: map() | nil + } + + @supported_types %{ + "ltiResourceLink" => :url_required, + "link" => :url_required, + "file" => :url_required, + "image" => :url_required, + "html" => :html_required + } + + @type input_type() :: String.t() | atom() + + def content_item(type, attrs \\ %{}) + + @spec content_item(input_type(), map()) :: {:ok, t()} | {:error, Errors.t()} + def content_item(type, attrs) when is_map(attrs) do + with {:ok, normalized_type} <- normalize_type(type), + :ok <- validate_type_specific_requirements(normalized_type, attrs) do + {:ok, + %__MODULE__{ + type: normalized_type, + url: fetch_string(attrs, "url"), + title: fetch_string(attrs, "title"), + text: fetch_string(attrs, "text"), + html: fetch_string(attrs, "html"), + icon: fetch_map(attrs, "icon"), + thumbnail: fetch_map(attrs, "thumbnail"), + line_item: fetch_map(attrs, "lineItem"), + iframe: fetch_map(attrs, "iframe"), + available: fetch_map(attrs, "available"), + custom: fetch_map(attrs, "custom"), + embed: fetch_map(attrs, "embed"), + window: fetch_map(attrs, "window") + }} + end + end + + def content_item(_type, _attrs) do + Errors.error(:content_item, :invalid_content_item, "Content item attrs must be a map") + end + + @spec to_claim(t()) :: map() + def to_claim(%__MODULE__{} = item) do + %{ + "type" => item.type, + "url" => item.url, + "title" => item.title, + "text" => item.text, + "html" => item.html, + "icon" => item.icon, + "thumbnail" => item.thumbnail, + "lineItem" => item.line_item, + "iframe" => item.iframe, + "available" => item.available, + "custom" => item.custom, + "embed" => item.embed, + "window" => item.window + } + |> Enum.reduce(%{}, fn + {_key, nil}, acc -> acc + {key, value}, acc -> Map.put(acc, key, value) + end) + end + + defp normalize_type(type) do + case type |> to_string() |> String.trim() do + "lti_resource_link" -> + {:ok, "ltiResourceLink"} + + "resource_link" -> + {:ok, "ltiResourceLink"} + + "ltiResourceLink" -> + {:ok, "ltiResourceLink"} + + "link" -> + {:ok, "link"} + + "file" -> + {:ok, "file"} + + "image" -> + {:ok, "image"} + + "html" -> + {:ok, "html"} + + unsupported -> + Errors.error( + :content_item, + :unsupported_content_item_type, + "Unsupported content item type #{unsupported}" + ) + end + end + + defp validate_type_specific_requirements(type, attrs) do + case @supported_types[type] do + :url_required -> + if valid_string?(Map.get(attrs, "url")) do + :ok + else + Errors.error( + :content_item, + :invalid_content_item, + "Content item type #{type} requires url", + %{type: type} + ) + end + + :html_required -> + if valid_string?(Map.get(attrs, "html")) do + :ok + else + Errors.error( + :content_item, + :invalid_content_item, + "Content item type #{type} requires html", + %{type: type} + ) + end + end + end + + defp fetch_string(attrs, key) do + case Map.get(attrs, key) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end + + defp fetch_map(attrs, key) do + case Map.get(attrs, key) do + value when is_map(value) -> value + _ -> nil + end + end + + defp valid_string?(value), do: is_binary(value) and value != "" +end diff --git a/lib/lti_1p3/tool/deep_linking/errors.ex b/lib/lti_1p3/tool/deep_linking/errors.ex new file mode 100644 index 0000000..4226cb7 --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/errors.ex @@ -0,0 +1,23 @@ +defmodule Lti_1p3.Tool.DeepLinking.Errors do + @moduledoc """ + Error helpers for tool deep-linking request validation and response generation. + """ + + @type t() :: %{ + reason: atom(), + stage: atom(), + msg: String.t(), + details: map() + } + + @spec error(atom(), atom(), String.t(), map()) :: {:error, t()} + def error(stage, reason, msg, details \\ %{}) + when is_atom(stage) and is_atom(reason) and is_binary(msg) and is_map(details) do + {:error, %{reason: reason, stage: stage, msg: msg, details: details}} + end + + @spec to_message_validator_error(t()) :: %{reason: atom(), msg: String.t(), details: map()} + def to_message_validator_error(%{reason: reason, msg: msg, details: details}) do + %{reason: reason, msg: msg, details: details} + end +end diff --git a/lib/lti_1p3/tool/deep_linking/jwt_signer.ex b/lib/lti_1p3/tool/deep_linking/jwt_signer.ex new file mode 100644 index 0000000..659f86e --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/jwt_signer.ex @@ -0,0 +1,56 @@ +defmodule Lti_1p3.Tool.DeepLinking.JwtSigner do + @moduledoc """ + JWT signing helper for deep-linking responses. + """ + + import Lti_1p3.Config + + alias Lti_1p3.Tool.DeepLinking.Errors + + @spec sign(map(), String.t()) :: {:ok, String.t()} | {:error, Errors.t()} + def sign(claims, issuer) when is_map(claims) and is_binary(issuer) do + with {:ok, active_jwk} <- resolve_active_jwk(), + {:ok, jwt} <- sign_claims(claims, issuer, active_jwk) do + {:ok, jwt} + end + end + + defp resolve_active_jwk do + case provider!().get_active_jwk() do + {:ok, active_jwk} -> + {:ok, active_jwk} + + {:error, reason} -> + Errors.error( + :signing, + :signing_key_not_available, + "Active signing JWK is not available", + %{ + reason: reason + } + ) + end + end + + defp sign_claims(claims, issuer, active_jwk) do + custom_header = %{"kid" => active_jwk.kid} + signer = Joken.Signer.create("RS256", %{"pem" => active_jwk.pem}, custom_header) + + claims = Map.put(claims, "iss", issuer) + + with {:ok, claims} <- Joken.generate_claims(%{}, claims), + {:ok, jwt, _claims} <- Joken.encode_and_sign(claims, signer) do + {:ok, jwt} + else + {:error, reason} -> + Errors.error( + :signing, + :response_signing_failed, + "Unable to sign deep-linking response", + %{ + reason: reason + } + ) + end + end +end diff --git a/lib/lti_1p3/tool/deep_linking/request.ex b/lib/lti_1p3/tool/deep_linking/request.ex new file mode 100644 index 0000000..c73a420 --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/request.ex @@ -0,0 +1,32 @@ +defmodule Lti_1p3.Tool.DeepLinking.Request do + @moduledoc """ + Typed deep-linking request derived from validated launch claims. + """ + + alias Lti_1p3.Tool.DeepLinking.Settings + + @enforce_keys [:issuer, :audience, :subject, :nonce, :version, :message_type, :roles, :settings] + defstruct [ + :issuer, + :audience, + :subject, + :nonce, + :version, + :message_type, + :roles, + :settings, + :claims + ] + + @type t() :: %__MODULE__{ + issuer: String.t(), + audience: String.t() | [String.t()], + subject: String.t(), + nonce: String.t(), + version: String.t(), + message_type: String.t(), + roles: [String.t()], + settings: Settings.t(), + claims: map() + } +end diff --git a/lib/lti_1p3/tool/deep_linking/request_validator.ex b/lib/lti_1p3/tool/deep_linking/request_validator.ex new file mode 100644 index 0000000..78a220d --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/request_validator.ex @@ -0,0 +1,106 @@ +defmodule Lti_1p3.Tool.DeepLinking.RequestValidator do + @moduledoc """ + Validates and parses `LtiDeepLinkingRequest` launch claims into typed structures. + """ + + alias Lti_1p3.DeepLinking.ClaimKeys + alias Lti_1p3.Tool.DeepLinking.Errors + alias Lti_1p3.Tool.DeepLinking.Request + alias Lti_1p3.Tool.DeepLinking.Settings + + @required_string_claims ["iss", "sub", "nonce"] + + @spec validate_request(map()) :: {:ok, Request.t()} | {:error, Errors.t()} + def validate_request(%{} = claims) do + with :ok <- validate_required_string_claims(claims), + :ok <- validate_message_type(claims), + :ok <- validate_version(claims), + {:ok, roles} <- validate_roles(claims), + {:ok, settings} <- validate_settings(claims) do + {:ok, + %Request{ + issuer: claims["iss"], + audience: claims["aud"], + subject: claims["sub"], + nonce: claims["nonce"], + version: claims[ClaimKeys.key(:version)], + message_type: claims[ClaimKeys.key(:message_type)], + roles: roles, + settings: settings, + claims: claims + }} + end + end + + def validate_request(_claims) do + Errors.error(:request, :invalid_deep_linking_request, "Launch claims must be a map") + end + + defp validate_required_string_claims(claims) do + case Enum.find(@required_string_claims, &(not valid_string?(Map.get(claims, &1)))) do + nil -> + :ok + + missing_claim -> + Errors.error( + :request, + :invalid_deep_linking_request, + "Missing or invalid #{missing_claim} claim", + %{claim: missing_claim} + ) + end + end + + defp validate_message_type(claims) do + if Map.get(claims, ClaimKeys.key(:message_type)) == "LtiDeepLinkingRequest" do + :ok + else + Errors.error( + :request, + :invalid_deep_linking_request, + "Message type must be LtiDeepLinkingRequest" + ) + end + end + + defp validate_version(claims) do + if Map.get(claims, ClaimKeys.key(:version)) == "1.3.0" do + :ok + else + Errors.error(:request, :invalid_deep_linking_request, "Incorrect version, expected 1.3.0") + end + end + + defp validate_roles(claims) do + case Map.get(claims, ClaimKeys.key(:roles)) do + roles when is_list(roles) and roles != [] -> + roles + |> Enum.filter(&is_binary/1) + |> case do + [] -> Errors.error(:request, :invalid_deep_linking_request, "Missing Roles Claim") + valid_roles -> {:ok, valid_roles} + end + + _ -> + Errors.error(:request, :invalid_deep_linking_request, "Missing Roles Claim") + end + end + + defp validate_settings(claims) do + claims + |> Map.get(ClaimKeys.key(:deep_linking_settings)) + |> case do + %{} = settings_claim -> + Settings.parse(settings_claim) + + _ -> + Errors.error( + :request, + :invalid_deep_linking_request, + "Missing deep linking settings claim" + ) + end + end + + defp valid_string?(value), do: is_binary(value) and value != "" +end diff --git a/lib/lti_1p3/tool/deep_linking/response_builder.ex b/lib/lti_1p3/tool/deep_linking/response_builder.ex new file mode 100644 index 0000000..fed00fb --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/response_builder.ex @@ -0,0 +1,91 @@ +defmodule Lti_1p3.Tool.DeepLinking.ResponseBuilder do + @moduledoc """ + Builds and signs `LtiDeepLinkingResponse` JWT payloads. + """ + + alias Lti_1p3.DeepLinking.ClaimKeys + alias Lti_1p3.Tool.DeepLinking.CompatibilityPolicy + alias Lti_1p3.Tool.DeepLinking.ContentItem + alias Lti_1p3.Tool.DeepLinking.Errors + alias Lti_1p3.Tool.DeepLinking.JwtSigner + alias Lti_1p3.Tool.DeepLinking.Request + alias Lti_1p3.Tool.DeepLinking.Telemetry + + def build_response(request, items, opts \\ []) + + @spec build_response(Request.t(), [ContentItem.t()], keyword()) :: + {:ok, %{jwt: String.t(), return_url: String.t()}} | {:error, Errors.t()} + def build_response(%Request{} = request, items, opts) when is_list(items) do + with {:ok, claim_items} <- to_claim_items(items), + {:ok, filtered_items} <- + CompatibilityPolicy.apply(claim_items, request.settings.accept_types, opts), + {:ok, claims} <- build_claims(request, filtered_items), + {:ok, jwt} <- JwtSigner.sign(claims, request.audience |> normalize_issuer()) do + Telemetry.emit_response(:ok, %{ + issuer: request.issuer, + client_id: normalize_client_id(request.audience), + item_count: length(filtered_items) + }) + + {:ok, %{jwt: jwt, return_url: request.settings.deep_link_return_url}} + else + {:error, %{reason: reason} = error} -> + Telemetry.emit_response(:error, %{ + issuer: request.issuer, + client_id: normalize_client_id(request.audience), + reason: reason, + item_count: length(items) + }) + + {:error, error} + end + end + + def build_response(_request, _items, _opts) do + Errors.error( + :response, + :invalid_deep_linking_request, + "Request must be a DeepLinking request" + ) + end + + defp to_claim_items(items) do + if Enum.all?(items, &match?(%ContentItem{}, &1)) do + {:ok, Enum.map(items, &ContentItem.to_claim/1)} + else + Errors.error( + :response, + :invalid_content_items, + "Content items must be built with Lti_1p3.Tool.DeepLinking.ContentItem" + ) + end + end + + defp build_claims(%Request{} = request, claim_items) do + claims = %{ + "aud" => request.issuer, + ClaimKeys.key(:message_type) => "LtiDeepLinkingResponse", + ClaimKeys.key(:version) => "1.3.0", + ClaimKeys.key(:content_items) => claim_items, + "nonce" => request.nonce + } + + claims = maybe_put_data(claims, request) + + {:ok, claims} + end + + defp maybe_put_data(claims, %Request{settings: %{data: nil}}), do: claims + + defp maybe_put_data(claims, %Request{settings: %{data: data}}) do + Map.put(claims, ClaimKeys.key(:data), data) + end + + # Tool deep-link responses use client_id as issuer. + defp normalize_issuer(aud) when is_binary(aud), do: aud + defp normalize_issuer([head | _tail]) when is_binary(head), do: head + + defp normalize_client_id(aud) when is_binary(aud), do: aud + defp normalize_client_id([head | _tail]) when is_binary(head), do: head + defp normalize_client_id(_), do: nil +end diff --git a/lib/lti_1p3/tool/deep_linking/settings.ex b/lib/lti_1p3/tool/deep_linking/settings.ex new file mode 100644 index 0000000..f07984d --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/settings.ex @@ -0,0 +1,104 @@ +defmodule Lti_1p3.Tool.DeepLinking.Settings do + @moduledoc """ + Typed deep-linking settings parsed from launch claims. + """ + + alias Lti_1p3.Tool.DeepLinking.Errors + + @enforce_keys [:deep_link_return_url, :accept_types, :accept_presentation_document_targets] + defstruct [ + :deep_link_return_url, + :accept_types, + :accept_presentation_document_targets, + :accept_media_types, + :accept_multiple, + :accept_lineitem, + :auto_create, + :title, + :text, + :data + ] + + @type t() :: %__MODULE__{ + deep_link_return_url: String.t(), + accept_types: [String.t()], + accept_presentation_document_targets: [String.t()], + accept_media_types: String.t() | nil, + accept_multiple: boolean() | nil, + accept_lineitem: boolean() | nil, + auto_create: boolean() | nil, + title: String.t() | nil, + text: String.t() | nil, + data: any() | nil + } + + @spec parse(map()) :: {:ok, t()} | {:error, Errors.t()} + def parse(%{} = settings_claim) do + with {:ok, deep_link_return_url} <- + fetch_required_string(settings_claim, "deep_link_return_url"), + {:ok, accept_types} <- fetch_required_string_list(settings_claim, "accept_types"), + {:ok, accept_targets} <- + fetch_required_string_list(settings_claim, "accept_presentation_document_targets") do + {:ok, + %__MODULE__{ + deep_link_return_url: deep_link_return_url, + accept_types: accept_types, + accept_presentation_document_targets: accept_targets, + accept_media_types: fetch_optional_string(settings_claim, "accept_media_types"), + accept_multiple: fetch_optional_boolean(settings_claim, "accept_multiple"), + accept_lineitem: fetch_optional_boolean(settings_claim, "accept_lineitem"), + auto_create: fetch_optional_boolean(settings_claim, "auto_create"), + title: fetch_optional_string(settings_claim, "title"), + text: fetch_optional_string(settings_claim, "text"), + data: Map.get(settings_claim, "data") + }} + end + end + + def parse(_value) do + Errors.error( + :request, + :invalid_deep_linking_settings, + "Deep linking settings claim must be a map" + ) + end + + defp fetch_required_string(map, key) do + case Map.get(map, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> Errors.error(:request, :invalid_deep_linking_settings, "Missing or invalid #{key}") + end + end + + defp fetch_required_string_list(map, key) do + case Map.get(map, key) do + values when is_list(values) -> + values + |> Enum.filter(&(is_binary(&1) and &1 != "")) + |> case do + [] -> + Errors.error(:request, :invalid_deep_linking_settings, "Missing or invalid #{key}") + + valid_values -> + {:ok, valid_values} + end + + _ -> + Errors.error(:request, :invalid_deep_linking_settings, "Missing or invalid #{key}") + end + end + + defp fetch_optional_string(map, key) do + case Map.get(map, key) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end + + defp fetch_optional_boolean(map, key) do + case Map.get(map, key) do + value when is_boolean(value) -> value + _ -> nil + end + end +end diff --git a/lib/lti_1p3/tool/deep_linking/telemetry.ex b/lib/lti_1p3/tool/deep_linking/telemetry.ex new file mode 100644 index 0000000..6b5327d --- /dev/null +++ b/lib/lti_1p3/tool/deep_linking/telemetry.ex @@ -0,0 +1,29 @@ +defmodule Lti_1p3.Tool.DeepLinking.Telemetry do + @moduledoc false + + require Logger + + @spec emit_request(:ok | :error, map()) :: :ok + def emit_request(result, metadata \\ %{}) do + emit([:lti_1p3, :tool, :deep_linking, :request], result, metadata) + end + + @spec emit_response(:ok | :error, map()) :: :ok + def emit_response(result, metadata \\ %{}) do + emit([:lti_1p3, :tool, :deep_linking, :response], result, metadata) + end + + defp emit(event, result, metadata) do + metadata = Map.merge(%{result: result}, metadata) + + :telemetry.execute(event, %{count: 1}, metadata) + + level = if result == :error, do: :warning, else: :info + + Logger.log(level, fn -> + "lti_1p3 tool_deep_linking event=#{Enum.join(Enum.map(event, &to_string/1), ".")} result=#{result} reason=#{Map.get(metadata, :reason)} issuer=#{Map.get(metadata, :issuer)} client_id=#{Map.get(metadata, :client_id)} item_count=#{Map.get(metadata, :item_count)}" + end) + + :ok + end +end diff --git a/lib/lti_1p3/tool/deployment.ex b/lib/lti_1p3/tool/deployment.ex index 1916790..5827030 100644 --- a/lib/lti_1p3/tool/deployment.ex +++ b/lib/lti_1p3/tool/deployment.ex @@ -3,9 +3,8 @@ defmodule Lti_1p3.Tool.Deployment do defstruct [:id, :deployment_id, :registration_id] @type t() :: %__MODULE__{ - id: integer(), - deployment_id: String.t(), - registration_id: integer() - } - + id: integer(), + deployment_id: String.t(), + registration_id: integer() + } end diff --git a/lib/lti_1p3/tool/launch.ex b/lib/lti_1p3/tool/launch.ex new file mode 100644 index 0000000..e3dce1a --- /dev/null +++ b/lib/lti_1p3/tool/launch.ex @@ -0,0 +1,29 @@ +defmodule Lti_1p3.Tool.Launch do + @moduledoc """ + Normalized tool launch payload returned by `Lti_1p3.Tool.validate_launch/3`. + """ + + @enforce_keys [:registration, :claims, :message_type, :deployment_id] + defstruct [:registration, :claims, :message_type, :deployment_id, :raw_claims] + + @type t() :: %__MODULE__{ + registration: map(), + claims: map(), + message_type: String.t(), + deployment_id: String.t(), + raw_claims: map() | nil + } + + @spec new(map(), map(), keyword()) :: t() + def new(registration, claims, opts \\ []) do + include_raw_claims = Keyword.get(opts, :raw_claims, false) + + %__MODULE__{ + registration: registration, + claims: claims, + message_type: claims["https://purl.imsglobal.org/spec/lti/claim/message_type"], + deployment_id: claims["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], + raw_claims: if(include_raw_claims, do: claims, else: nil) + } + end +end diff --git a/lib/lti_1p3/tool/launch_validation.ex b/lib/lti_1p3/tool/launch_validation.ex index bc2ca1b..804c8f6 100644 --- a/lib/lti_1p3/tool/launch_validation.ex +++ b/lib/lti_1p3/tool/launch_validation.ex @@ -1,146 +1,161 @@ defmodule Lti_1p3.Tool.LaunchValidation do - import Lti_1p3.Config - import Lti_1p3.Utils + @moduledoc """ + Stage-based LTI launch validation pipeline. + """ - @message_validators [ - Lti_1p3.Tool.MessageValidators.ResourceMessageValidator - ] + alias Lti_1p3.Core.Telemetry + alias Lti_1p3.Core.Validation.Deployment + alias Lti_1p3.Core.Validation.Jwt + alias Lti_1p3.Core.Validation.Message + alias Lti_1p3.Core.Validation.Nonce + alias Lti_1p3.Core.Validation.Registration + alias Lti_1p3.Core.Validation.State + alias Lti_1p3.Core.Validation.Timestamps + alias Lti_1p3.Tool.Launch - @type params() :: %{state: binary(), id_token: binary()} - @type validate_opts() :: [] + @type params() :: %{optional(String.t()) => String.t()} + @type validate_opts() :: [raw_claims: boolean(), correlation_id: String.t()] @doc """ - Validates an incoming LTI 1.3 launch and returns the claims if successful. + Validates an incoming LTI 1.3 launch and returns a normalized launch struct. """ - @spec validate(params(), validate_opts()) :: - {:ok, any()} | {:error, %{optional(atom()) => any(), reason: atom(), msg: String.t()}} - def validate(params, session_state, _opts \\ []) do - with {:ok} <- validate_oidc_state(params, session_state), - {:ok, registration} <- validate_registration(params), - {:ok, key_set_url} <- registration_key_set_url(registration), - {:ok, id_token} <- extract_param(params, "id_token"), - {:ok, jwt_body} <- validate_jwt_signature(id_token, key_set_url), - {:ok} <- validate_timestamps(jwt_body), - {:ok} <- validate_deployment(registration, jwt_body), - {:ok} <- validate_message(jwt_body), - {:ok} <- validate_nonce(jwt_body, "validate_launch"), - claims <- jwt_body do - {:ok, claims} + @spec validate(params(), String.t() | nil, validate_opts()) :: + {:ok, Launch.t()} + | {:error, %{reason: atom(), stage: atom(), msg: String.t(), details: map()}} + def validate(params, session_state, opts \\ []) do + correlation_id = Keyword.get(opts, :correlation_id, UUID.uuid4()) + + with :ok <- validate_state(session_state, params, correlation_id), + {:ok, registration, _peeked_claims} <- resolve_registration(params, correlation_id), + {:ok, id_token} <- fetch_id_token(params), + {:ok, claims} <- validate_jwt(id_token, registration, correlation_id), + :ok <- validate_timestamps(claims, correlation_id), + {:ok, deployment_id} <- validate_deployment(registration, claims, correlation_id), + {:ok, _message_type} <- validate_message(claims, correlation_id), + :ok <- validate_nonce(claims, correlation_id) do + launch = Launch.new(registration, claims, opts) + Telemetry.emit_outcome(:ok, %{flow: :tool_launch, correlation_id: correlation_id}) + {:ok, %{launch | deployment_id: deployment_id}} + else + {:error, error} = result -> + Telemetry.emit_outcome(:error, %{ + flow: :tool_launch, + correlation_id: correlation_id, + reason: error.reason, + stage: error.stage + }) + + result end end - # Validate that the state sent with an OIDC launch matches the state that was sent in the OIDC response - # returns a boolean on whether it is valid or not - defp validate_oidc_state(params, session_state) do - case session_state do - nil -> - {:error, - %{ - reason: :invalid_oidc_state, - msg: - "State from session is missing. Make sure cookies are enabled and configured correctly" - }} - - session_state -> - case params["state"] do - nil -> - {:error, %{reason: :invalid_oidc_state, msg: "State from OIDC request is missing"}} - - request_state -> - if request_state == session_state do - {:ok} - else - {:error, - %{ - reason: :invalid_oidc_state, - msg: "State from OIDC request does not match session" - }} - end - end + defp validate_state(session_state, params, correlation_id) do + case State.validate(session_state, Map.get(params, "state")) do + :ok -> + emit_stage_ok(:state, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:state, error, correlation_id) + end + end + + defp resolve_registration(params, correlation_id) do + case Registration.resolve(params) do + {:ok, registration, peeked_claims} -> + emit_stage_ok(:registration, correlation_id) + {:ok, registration, peeked_claims} + + {:error, error} -> + emit_stage_error(:registration, error, correlation_id) end end - defp validate_registration(params) do - with {:ok, issuer, client_id} <- peek_issuer_client_id(params) do - case provider!().get_registration_by_issuer_client_id(issuer, client_id) do - nil -> - {:error, - %{ - reason: :invalid_registration, - msg: - "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found", - issuer: issuer, - client_id: client_id - }} - - registration -> - {:ok, registration} - end + defp validate_jwt(id_token, registration, correlation_id) do + case Jwt.validate(id_token, registration) do + {:ok, claims} -> + emit_stage_ok(:jwt, correlation_id) + {:ok, claims} + + {:error, error} -> + emit_stage_error(:jwt, error, correlation_id) end end - defp peek_issuer_client_id(params) do - with {:ok, jwt_string} <- extract_param(params, "id_token"), - {:ok, jwt_claims} <- peek_claims(jwt_string) do - {:ok, jwt_claims["iss"], peek_client_id(jwt_claims["aud"])} + defp validate_timestamps(claims, correlation_id) do + case Timestamps.validate(claims) do + :ok -> + emit_stage_ok(:timestamps, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:timestamps, error, correlation_id) end end - defp peek_client_id([client_id | _]), do: client_id - defp peek_client_id(client_id), do: client_id + defp validate_deployment(registration, claims, correlation_id) do + case Deployment.validate(registration, claims) do + {:ok, deployment_id} -> + emit_stage_ok(:deployment, correlation_id) + {:ok, deployment_id} - defp validate_deployment(registration, jwt_body) do - deployment_id = jwt_body["https://purl.imsglobal.org/spec/lti/claim/deployment_id"] - deployment = provider!().get_deployment(registration, deployment_id) + {:error, error} -> + emit_stage_error(:deployment, error, correlation_id) + end + end - case deployment do - nil -> - {:error, - %{ - reason: :invalid_deployment, - msg: "Deployment with id \"#{deployment_id}\" not found", - registration_id: registration.id, - deployment_id: deployment_id - }} - - _deployment -> - {:ok} + defp validate_message(claims, correlation_id) do + case Message.validate(claims) do + {:ok, message_type} -> + emit_stage_ok(:message, correlation_id) + {:ok, message_type} + + {:error, error} -> + emit_stage_error(:message, error, correlation_id) end end - defp validate_message(jwt_body) do - case jwt_body["https://purl.imsglobal.org/spec/lti/claim/message_type"] do - # JOSE >= 1.11.12 decodes JSON null as the atom :null instead of nil - value when value == :null or is_nil(value) -> - {:error, %{reason: :invalid_message_type, msg: "Missing message type"}} - - message_type -> - # no more than one message validator should apply for a given mesage, - # so use the first validator we find that applies - validation_result = - case Enum.find(@message_validators, fn mv -> mv.can_validate(jwt_body) end) do - nil -> nil - validator -> validator.validate(jwt_body) - end - - case validation_result do - nil -> - {:error, - %{ - reason: :invalid_message_type, - msg: "Invalid or unsupported message type \"#{message_type}\"" - }} - - {:error, error} -> - {:error, - %{ - reason: :invalid_message, - msg: "Message validation failed: (\"#{message_type}\") #{error}" - }} - - _ -> - {:ok} - end + defp validate_nonce(claims, correlation_id) do + case Nonce.validate(claims, "validate_launch") do + :ok -> + emit_stage_ok(:nonce, correlation_id) + :ok + + {:error, error} -> + emit_stage_error(:nonce, error, correlation_id) end end + + defp fetch_id_token(params) do + case Map.get(params, "id_token") do + nil -> + {:error, %{reason: :missing_param, stage: :jwt, msg: "Missing id_token", details: %{}}} + + id_token -> + {:ok, id_token} + end + end + + defp emit_stage_ok(stage, correlation_id) do + Telemetry.emit_stage(stage, :ok, %{flow: :tool_launch, correlation_id: correlation_id}) + end + + defp emit_stage_error(stage, error, correlation_id) do + error = ensure_error_shape(error, stage) + + Telemetry.emit_stage(stage, :error, %{ + flow: :tool_launch, + correlation_id: correlation_id, + reason: error.reason, + stage: error.stage + }) + + {:error, error} + end + + defp ensure_error_shape(error, stage) do + error + |> Map.put_new(:stage, stage) + |> Map.put_new(:details, %{}) + end end diff --git a/lib/lti_1p3/tool/message_dispatch.ex b/lib/lti_1p3/tool/message_dispatch.ex new file mode 100644 index 0000000..c1b0c36 --- /dev/null +++ b/lib/lti_1p3/tool/message_dispatch.ex @@ -0,0 +1,21 @@ +defmodule Lti_1p3.Tool.MessageDispatch do + @moduledoc """ + Dispatches LTI launch message validation by message type. + """ + + alias Lti_1p3.Tool.MessageValidators.DeepLinkingMessageValidator + alias Lti_1p3.Tool.MessageValidators.ResourceMessageValidator + + @validators [ + ResourceMessageValidator, + DeepLinkingMessageValidator + ] + + @spec validate(map()) :: :ok | :unsupported | {:error, map()} | {:error, atom(), String.t()} + def validate(claims) do + case Enum.find(@validators, & &1.can_validate?(claims)) do + nil -> :unsupported + validator -> validator.validate(claims) + end + end +end diff --git a/lib/lti_1p3/tool/message_vaildators/message_validator.ex b/lib/lti_1p3/tool/message_vaildators/message_validator.ex deleted file mode 100644 index 49c23d9..0000000 --- a/lib/lti_1p3/tool/message_vaildators/message_validator.ex +++ /dev/null @@ -1,8 +0,0 @@ -defprotocol Lti_1p3.Tool.MessageValidator do - - @spec can_validate(any) :: boolean - def can_validate(jwt_body) - - @spec validate(any) :: {:ok} | {:error, String.t()} - def validate(jwt_body) -end diff --git a/lib/lti_1p3/tool/message_vaildators/resource_message_validator.ex b/lib/lti_1p3/tool/message_vaildators/resource_message_validator.ex deleted file mode 100644 index 0783b9c..0000000 --- a/lib/lti_1p3/tool/message_vaildators/resource_message_validator.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Lti_1p3.Tool.MessageValidators.ResourceMessageValidator do - - @behaviour Lti_1p3.Tool.MessageValidator - - def can_validate(jwt_body) do - jwt_body["https://purl.imsglobal.org/spec/lti/claim/message_type"] == "LtiResourceLinkRequest" - end - - def validate(jwt_body) do - with {:ok} <- user_sub(jwt_body), - {:ok} <- lti_version(jwt_body), - {:ok} <- roles_claim(jwt_body), - {:ok} <- resource_link_id(jwt_body) - do - {:ok} - else - {:error, error} -> {:error, error} - end - end - - defp user_sub(jwt_body) do - case jwt_body["sub"] do - nil -> - {:error, "Must have a user (sub)"} - _ -> - {:ok} - end - end - - defp lti_version(jwt_body) do - if jwt_body["https://purl.imsglobal.org/spec/lti/claim/version"] != "1.3.0" do - {:error, "Incorrect version, expected 1.3.0"} - else - {:ok} - end - end - - defp roles_claim(jwt_body) do - case jwt_body["https://purl.imsglobal.org/spec/lti/claim/roles"] do - nil -> - {:error, "Missing Roles Claim"} - _ -> - {:ok} - end - end - - defp resource_link_id(jwt_body) do - case jwt_body["https://purl.imsglobal.org/spec/lti/claim/resource_link"]["id"] do - nil -> - {:error, "Missing Resource Link Id"} - _ -> - {:ok} - end - end -end diff --git a/lib/lti_1p3/tool/message_validators/deep_linking_message_validator.ex b/lib/lti_1p3/tool/message_validators/deep_linking_message_validator.ex new file mode 100644 index 0000000..dd8c46b --- /dev/null +++ b/lib/lti_1p3/tool/message_validators/deep_linking_message_validator.ex @@ -0,0 +1,27 @@ +defmodule Lti_1p3.Tool.MessageValidators.DeepLinkingMessageValidator do + @moduledoc """ + Validates deep-linking launch claims via typed request validation. + """ + + @behaviour Lti_1p3.Tool.MessageValidator + + alias Lti_1p3.DeepLinking.ClaimKeys + alias Lti_1p3.Tool.DeepLinking.Errors + alias Lti_1p3.Tool.DeepLinking.RequestValidator + + @impl true + def can_validate?(claims) do + Map.get(claims, ClaimKeys.key(:message_type)) == "LtiDeepLinkingRequest" + end + + @impl true + def validate(claims) do + case RequestValidator.validate_request(claims) do + {:ok, _request} -> + :ok + + {:error, error} -> + {:error, Errors.to_message_validator_error(error)} + end + end +end diff --git a/lib/lti_1p3/tool/message_validators/message_validator.ex b/lib/lti_1p3/tool/message_validators/message_validator.ex new file mode 100644 index 0000000..4c4b791 --- /dev/null +++ b/lib/lti_1p3/tool/message_validators/message_validator.ex @@ -0,0 +1,6 @@ +defmodule Lti_1p3.Tool.MessageValidator do + @moduledoc false + + @callback can_validate?(map()) :: boolean() + @callback validate(map()) :: :ok | {:error, map()} +end diff --git a/lib/lti_1p3/tool/message_validators/resource_message_validator.ex b/lib/lti_1p3/tool/message_validators/resource_message_validator.ex new file mode 100644 index 0000000..906da92 --- /dev/null +++ b/lib/lti_1p3/tool/message_validators/resource_message_validator.ex @@ -0,0 +1,51 @@ +defmodule Lti_1p3.Tool.MessageValidators.ResourceMessageValidator do + @moduledoc false + + @behaviour Lti_1p3.Tool.MessageValidator + + @message_type_claim "https://purl.imsglobal.org/spec/lti/claim/message_type" + @version_claim "https://purl.imsglobal.org/spec/lti/claim/version" + @roles_claim "https://purl.imsglobal.org/spec/lti/claim/roles" + @resource_link_claim "https://purl.imsglobal.org/spec/lti/claim/resource_link" + + @impl true + def can_validate?(claims) do + Map.get(claims, @message_type_claim) == "LtiResourceLinkRequest" + end + + @impl true + def validate(claims) do + with :ok <- required_claim(claims, "sub", :invalid_message, "Must have a user (sub)"), + :ok <- validate_version(claims), + :ok <- required_claim(claims, @roles_claim, :invalid_message, "Missing Roles Claim"), + :ok <- validate_resource_link_id(claims) do + :ok + end + end + + defp validate_version(claims) do + if Map.get(claims, @version_claim) == "1.3.0" do + :ok + else + {:error, error(:invalid_message, "Incorrect version, expected 1.3.0")} + end + end + + defp validate_resource_link_id(claims) do + case get_in(claims, [@resource_link_claim, "id"]) do + nil -> {:error, error(:invalid_message, "Missing Resource Link Id")} + _id -> :ok + end + end + + defp required_claim(claims, key, reason, msg) do + case Map.get(claims, key) do + nil -> {:error, error(reason, msg)} + _value -> :ok + end + end + + defp error(reason, msg) do + %{reason: reason, msg: msg, details: %{validator: :resource_link_request}, stage: :message} + end +end diff --git a/lib/lti_1p3/tool/oidc_login.ex b/lib/lti_1p3/tool/oidc_login.ex index 861eb89..eaf55bc 100644 --- a/lib/lti_1p3/tool/oidc_login.ex +++ b/lib/lti_1p3/tool/oidc_login.ex @@ -1,16 +1,17 @@ defmodule Lti_1p3.Tool.OidcLogin do + @moduledoc """ + Tool-side OIDC login request validation and redirect URL construction. + """ + import Lti_1p3.Config - def oidc_login_redirect_url(params) do - with {:ok, _issuer, login_hint, registration} <- validate_oidc_login(params) do - # craft OIDC auth response + @type error_map :: %{reason: atom(), stage: atom(), msg: String.t(), details: map()} - # create unique state. Be sure to add this state to conn - # - # ## Example: - # conn = conn - # |> put_session("state", state) - state = UUID.uuid4() + @spec oidc_login_redirect_url(map(), keyword()) :: + {:ok, String.t(), String.t()} | {:error, error_map()} + def oidc_login_redirect_url(params, _opts \\ []) do + with {:ok, _issuer, login_hint, registration} <- validate_oidc_login(params) do + state = UUID.uuid4() query_params = %{ "scope" => "openid", @@ -21,14 +22,14 @@ defmodule Lti_1p3.Tool.OidcLogin do "redirect_uri" => params["target_link_uri"], "state" => state, "nonce" => UUID.uuid4(), - "login_hint" => login_hint, + "login_hint" => login_hint } - # pass back LTI message hint if given - query_params = case params["lti_message_hint"] do - nil -> query_params - lti_message_hint -> Map.put_new(query_params, "lti_message_hint", lti_message_hint) - end + query_params = + case params["lti_message_hint"] do + nil -> query_params + lti_message_hint -> Map.put_new(query_params, "lti_message_hint", lti_message_hint) + end redirect_url = registration.auth_login_url <> "?" <> URI.encode_query(query_params) @@ -39,22 +40,21 @@ defmodule Lti_1p3.Tool.OidcLogin do defp validate_oidc_login(params) do with {:ok, issuer} <- validate_issuer(params), {:ok, login_hint} <- validate_login_hint(params), - {:ok, registration} <- validate_registration(params) - do + {:ok, registration} <- validate_registration(params) do {:ok, issuer, login_hint, registration} end end defp validate_issuer(params) do case params["iss"] do - nil -> {:error, %{reason: :missing_issuer, msg: "Request does not have an issuer (iss)"}} + nil -> error(:missing_issuer, "Request does not have an issuer (iss)") issuer -> {:ok, issuer} end end defp validate_login_hint(params) do case params["login_hint"] do - nil -> {:error, %{reason: :missing_login_hint, msg: "Request does not have a login hint (login_hint)"}} + nil -> error(:missing_login_hint, "Request does not have a login hint (login_hint)") login_hint -> {:ok, login_hint} end end @@ -66,15 +66,18 @@ defmodule Lti_1p3.Tool.OidcLogin do case provider!().get_registration_by_issuer_client_id(issuer, client_id) do nil -> - {:error, %{ - reason: :invalid_registration, - msg: "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found", - issuer: issuer, - client_id: client_id, - lti_deployment_id: lti_deployment_id - }} + error( + :invalid_registration, + "Registration with issuer \"#{issuer}\" and client id \"#{client_id}\" not found", + %{issuer: issuer, client_id: client_id, lti_deployment_id: lti_deployment_id} + ) + registration -> {:ok, registration} end end + + defp error(reason, msg, details \\ %{}) do + {:error, %{reason: reason, stage: :login, msg: msg, details: details}} + end end diff --git a/lib/lti_1p3/tool/registration.ex b/lib/lti_1p3/tool/registration.ex index fed5a23..08913aa 100644 --- a/lib/lti_1p3/tool/registration.ex +++ b/lib/lti_1p3/tool/registration.ex @@ -20,14 +20,13 @@ defmodule Lti_1p3.Tool.Registration do ] @type t() :: %__MODULE__{ - id: integer(), - issuer: String.t(), - client_id: String.t(), - key_set_url: String.t(), - auth_token_url: String.t(), - auth_login_url: String.t(), - auth_server: String.t(), - tool_jwk_id: integer() - } - + id: integer(), + issuer: String.t(), + client_id: String.t(), + key_set_url: String.t(), + auth_token_url: String.t(), + auth_login_url: String.t(), + auth_server: String.t(), + tool_jwk_id: integer() + } end diff --git a/lib/lti_1p3/tool/services/ags.ex b/lib/lti_1p3/tool/services/ags.ex index d6da579..3a0a579 100644 --- a/lib/lti_1p3/tool/services/ags.ex +++ b/lib/lti_1p3/tool/services/ags.ex @@ -26,9 +26,14 @@ defmodule Lti_1p3.Tool.Services.AGS do body = score |> Jason.encode!() - case http_client!().post(build_url_with_path(line_item.id, "scores"), body, score_headers(access_token)) do + case http_client!().post( + build_url_with_path(line_item.id, "scores"), + body, + score_headers(access_token) + ) do {:ok, %HTTPoison.Response{status_code: code, body: body}} when code in [200, 201] -> {:ok, body} + e -> Logger.error( "Error encountered posting score for user #{score.userId} for line item '#{line_item.label}' #{inspect(e)}" @@ -59,7 +64,9 @@ defmodule Lti_1p3.Tool.Services.AGS do # here as a Torus "resource_id" is strictly coincidence. prefixed_resource_id = LineItem.to_resource_id(resource_id) - request_url = build_url_with_params(line_items_service_url, "resource_id=#{prefixed_resource_id}&limit=1") + + request_url = + build_url_with_params(line_items_service_url, "resource_id=#{prefixed_resource_id}&limit=1") Logger.info("fetch_or_create_line_item: URL #{request_url}") @@ -81,8 +88,9 @@ defmodule Lti_1p3.Tool.Services.AGS do # it is important to match against a possible array of items, in case an LMS does # not properly support the limit parameter [raw_line_item | _] -> - - Logger.info("fetch_or_create_line_item: Retrieved raw line item #{inspect(raw_line_item)} for #{resource_id} #{label}") + Logger.info( + "fetch_or_create_line_item: Retrieved raw line item #{inspect(raw_line_item)} for #{resource_id} #{label}" + ) line_item = to_line_item(raw_line_item) @@ -240,7 +248,9 @@ defmodule Lti_1p3.Tool.Services.AGS do end defp get_line_items_domain(%{line_items_service_domain: domain}, default) - when is_nil(domain) or domain == "", do: default + when is_nil(domain) or domain == "", + do: default + defp get_line_items_domain(%{line_items_service_domain: domain}, _default), do: domain defp get_line_items_domain(_registration, default), do: default @@ -275,8 +285,8 @@ defmodule Lti_1p3.Tool.Services.AGS do end defp score_headers(%AccessToken{} = access_token) do - [{"Content-Type", "application/vnd.ims.lis.v1.score+json"}] - ++ access_token_header(access_token.access_token) + [{"Content-Type", "application/vnd.ims.lis.v1.score+json"}] ++ + access_token_header(access_token.access_token) end defp access_token_header(access_token), diff --git a/lib/lti_1p3/utils.ex b/lib/lti_1p3/utils.ex index cab0e8d..c74895e 100644 --- a/lib/lti_1p3/utils.ex +++ b/lib/lti_1p3/utils.ex @@ -115,14 +115,27 @@ defmodule Lti_1p3.Utils do end def validate_audience(jwt, audience) do - audience_claims = String.split(jwt["aud"], ",", trim: true) + audience_claim = jwt["aud"] - if audience_claims in audience do + valid? = + cond do + is_binary(audience_claim) -> + audience_claim == audience + + is_list(audience_claim) -> + audience in audience_claim or + (length(audience_claim) > 1 and Map.get(jwt, "azp") == audience) + + true -> + false + end + + if valid? do {:ok} else {:error, %{ - reason: :invalid_issuer, + reason: :invalid_audience, msg: "Audience ('aud' claim) in JWT doesn't contain the expected audience" }} end diff --git a/mix.exs b/mix.exs index 6587360..14c39a8 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Lti_1p3.MixProject do def project do [ app: :lti_1p3, - version: "0.11.0", + version: "1.0.0", elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), elixirc_options: elixirc_options(Mix.env()), @@ -41,6 +41,7 @@ defmodule Lti_1p3.MixProject do {:jason, "~> 1.3"}, {:joken, "~> 2.2"}, {:mox, "~> 0.5", only: :test}, + {:telemetry, "~> 1.2"}, {:timex, "~> 3.5"}, {:uuid, "~> 1.1"} ] @@ -78,7 +79,13 @@ defmodule Lti_1p3.MixProject do main: "readme", extras: [ "README.md", - "docs/lti_1p3_overview.md" + "docs/lti_1p3_overview.md", + "docs/core_tool_platform_guide.md", + "docs/tool_deep_linking_guide.md", + "docs/telemetry.md", + "docs/core_migration_guide.md", + "docs/provider_adapter_migration.md", + "docs/core_troubleshooting.md" ], groups_for_extras: [ "LTI 1.3": Path.wildcard("docs/*.md") diff --git a/mix.lock b/mix.lock index 083cf60..749c3c5 100644 --- a/mix.lock +++ b/mix.lock @@ -26,7 +26,7 @@ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "timex": {:hex, :timex, "3.6.3", "58ce6c9eda8ed47fc80c24dde09d481465838d3bcfc230949287fc1b0b0041c1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "6d69f4f95fcf5684102a9cb3cf92c5ba6545bd60ed8d8a6a93cd2a4a4fb0d9ec"}, "tzdata": {:hex, :tzdata, "1.0.5", "69f1ee029a49afa04ad77801febaf69385f3d3e3d1e4b56b9469025677b89a28", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "55519aa2a99e5d2095c1e61cc74c9be69688f8ab75c27da724eb8279ff402a5a"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, diff --git a/test/lti_1p3/data_providers/memory_provider.exs b/test/lti_1p3/data_providers/memory_provider.exs index 1e99184..baa5098 100644 --- a/test/lti_1p3/data_providers/memory_provider.exs +++ b/test/lti_1p3/data_providers/memory_provider.exs @@ -2,5 +2,4 @@ defmodule Lti_1p3.DataProviders.EctoProviderTest do use Lti_1p3.Test.TestCase alias Lti_1p3.DataProviders.MemoryProvider - end diff --git a/test/lti_1p3/key_generator_test.exs b/test/lti_1p3/key_generator_test.exs index 52ed6cf..b0541f7 100644 --- a/test/lti_1p3/key_generator_test.exs +++ b/test/lti_1p3/key_generator_test.exs @@ -5,16 +5,15 @@ defmodule Lti_1p3.KeyGeneratorTest do describe "key generator" do test "passphrase/0 generates a random passphrase of size 256" do - assert String.length(KeyGenerator.passphrase) == 256 + assert String.length(KeyGenerator.passphrase()) == 256 end test "generate_key_pair/0 generates a public and private key pair" do - keypair = KeyGenerator.generate_key_pair + keypair = KeyGenerator.generate_key_pair() assert Map.has_key?(keypair, :public_key) assert Map.has_key?(keypair, :private_key) assert Map.has_key?(keypair, :key_id) end - end end diff --git a/test/lti_1p3/lti_1p3_test.exs b/test/lti_1p3/lti_1p3_test.exs index e0eb529..f376ab2 100644 --- a/test/lti_1p3/lti_1p3_test.exs +++ b/test/lti_1p3/lti_1p3_test.exs @@ -7,13 +7,14 @@ defmodule Lti_1p3Test do test "should create and get the active jwk" do %{private_key: private_key} = Lti_1p3.KeyGenerator.generate_key_pair() - {:ok, jwk} = Lti_1p3.create_jwk(%Jwk{ - pem: private_key, - typ: "JWT", - alg: "RS256", - kid: UUID.uuid4(), - active: true, - }) + {:ok, jwk} = + Lti_1p3.create_jwk(%Jwk{ + pem: private_key, + typ: "JWT", + alg: "RS256", + kid: UUID.uuid4(), + active: true + }) assert Lti_1p3.get_active_jwk() == {:ok, jwk} end @@ -21,44 +22,47 @@ defmodule Lti_1p3Test do test "should get all public keys" do %{private_key: private_key} = Lti_1p3.KeyGenerator.generate_key_pair() - {:ok, jwk1} = Lti_1p3.create_jwk(%Jwk{ - pem: private_key, - typ: "JWT", - alg: "RS256", - kid: UUID.uuid4(), - active: false, - }) + {:ok, jwk1} = + Lti_1p3.create_jwk(%Jwk{ + pem: private_key, + typ: "JWT", + alg: "RS256", + kid: UUID.uuid4(), + active: false + }) - {:ok, jwk2} = Lti_1p3.create_jwk(%Jwk{ - pem: private_key, - typ: "JWT", - alg: "RS256", - kid: UUID.uuid4(), - active: true, - }) + {:ok, jwk2} = + Lti_1p3.create_jwk(%Jwk{ + pem: private_key, + typ: "JWT", + alg: "RS256", + kid: UUID.uuid4(), + active: true + }) - {:ok, jwk3} = Lti_1p3.create_jwk(%Jwk{ - pem: private_key, - typ: "JWT", - alg: "RS256", - kid: UUID.uuid4(), - active: true, - }) + {:ok, jwk3} = + Lti_1p3.create_jwk(%Jwk{ + pem: private_key, + typ: "JWT", + alg: "RS256", + kid: UUID.uuid4(), + active: true + }) assert Lti_1p3.get_all_public_keys() == %{ - keys: [ - to_public_key(jwk1), - to_public_key(jwk2), - to_public_key(jwk3), - ] - } + keys: [ + to_public_key(jwk1), + to_public_key(jwk2), + to_public_key(jwk3) + ] + } end end defp to_public_key(%Jwk{pem: pem, typ: typ, alg: alg, kid: kid}) do pem - |> JOSE.JWK.from_pem - |> JOSE.JWK.to_public + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() |> (fn {_kty, public_jwk} -> public_jwk end).() |> Map.put("typ", typ) diff --git a/test/lti_1p3/nonces_test.exs b/test/lti_1p3/nonces_test.exs index 81d2aa1..efbef2d 100644 --- a/test/lti_1p3/nonces_test.exs +++ b/test/lti_1p3/nonces_test.exs @@ -31,7 +31,8 @@ defmodule Lti_1p3.NoncesTest do test "should fail to create new nonce if one already exists with specified domain" do {:ok, _nonce} = Nonces.create_nonce("some-value", "some-domain") - assert {:error, %Lti_1p3.DataProviderError{msg: "Nonce with value already exists"}} = Nonces.create_nonce("some-value", "some-domain") + assert {:error, %Lti_1p3.DataProviderError{msg: "Nonce with value already exists"}} = + Nonces.create_nonce("some-value", "some-domain") end test "should cleanup expired nonces" do @@ -41,9 +42,18 @@ defmodule Lti_1p3.NoncesTest do assert Nonces.get_nonce(nonce.value) == nonce # fake the nonce was created a day + 1 hour ago - a_day_before = Timex.now |> Timex.subtract(Timex.Duration.from_hours(25)) + a_day_before = Timex.now() |> Timex.subtract(Timex.Duration.from_hours(25)) + Agent.update(MemoryProvider, fn state -> - %{state | nonces: state.nonces |> Map.put(MemoryProvider.nonce_key(nonce), Map.put(nonce, :inserted_at, a_day_before))} + %{ + state + | nonces: + state.nonces + |> Map.put( + MemoryProvider.nonce_key(nonce), + Map.put(nonce, :inserted_at, a_day_before) + ) + } end) # run cleanup diff --git a/test/lti_1p3/platform/authorization_redirect_test.exs b/test/lti_1p3/platform/authorization_redirect_test.exs index 80850bf..47ae576 100644 --- a/test/lti_1p3/platform/authorization_redirect_test.exs +++ b/test/lti_1p3/platform/authorization_redirect_test.exs @@ -5,23 +5,20 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do import Mox alias Lti_1p3.Claims + alias Lti_1p3.Platform alias Lti_1p3.Platform.AuthorizationRedirect - alias Lti_1p3.Test.MockHTTPoison alias Lti_1p3.Platform.LoginHint alias Lti_1p3.Platform.LoginHints alias Lti_1p3.Platform.PlatformInstance + alias Lti_1p3.Test.MockHTTPoison - # Make sure mocks are verified when the test exits setup [:create_active_jwk, :setup_key_provider, :verify_on_exit!] defp setup_key_provider(_context) do - # Configure the HTTP client for the key provider Application.put_env(:lti_1p3, :http_client, MockHTTPoison) - # Start the key provider for JWT validation {:ok, _pid} = Lti_1p3.KeyProviders.MemoryKeyProvider.start_link([]) - # Allow the test process to use the mock Mox.allow(MockHTTPoison, self(), Lti_1p3.KeyProviders.MemoryKeyProvider) :ok @@ -36,7 +33,8 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do params: params, target_link_uri: target_link_uri, user: user - } = generate_lti_platform_stubs() + } = + generate_lti_platform_stubs() claims = [ Claims.MessageType.message_type(:lti_resource_link_request), @@ -49,55 +47,47 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do assert {:ok, ^target_link_uri, ^state, id_token} = AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) - # validate the id_token returned is signed correctly {:ok, active_jwk} = provider!().get_active_jwk() - # Mock the HTTP request that the key provider will make - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(active_jwk) end) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(active_jwk) end) assert {:ok, jwt} = Lti_1p3.Utils.validate_jwt_signature(id_token, "some-keyset-url") - - assert jwt["exp"] - assert jwt["iat"] - assert jwt["nbf"] - assert jwt["nonce"] - assert jwt["iss"] == issuer assert jwt["aud"] == "some-client-id" - assert jwt["sub"] == user.sub - assert jwt["given_name"] == user.given_name - assert jwt["family_name"] == user.family_name - assert jwt["middle_name"] == user.middle_name - assert jwt["name"] == user.name - assert jwt["email"] == user.email - assert jwt["locale"] == user.locale - assert jwt["picture"] == user.picture - - assert jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"] == - "LtiResourceLinkRequest" - - assert jwt["https://purl.imsglobal.org/spec/lti/claim/version"] == "1.3.0" - assert jwt["https://purl.imsglobal.org/spec/lti/claim/deployment_id"] == deployment_id - assert jwt["https://purl.imsglobal.org/spec/lti/claim/target_link_uri"] == "some-valid-url" - - assert jwt["https://purl.imsglobal.org/spec/lti/claim/resource_link"] == - %{"id" => "some-resource-link-id"} - - assert jwt["https://purl.imsglobal.org/spec/lti/claim/roles"] == [] end - test "fails on missing oidc params" do + test "top-level Platform API returns authorization payload struct" do %{ issuer: issuer, deployment_id: deployment_id, params: params, + target_link_uri: target_link_uri, + state: state, user: user - } = generate_lti_platform_stubs() + } = + generate_lti_platform_stubs() + + claims = [ + Claims.MessageType.message_type(:lti_resource_link_request), + Claims.DeploymentId.deployment_id(deployment_id), + Claims.TargetLinkUri.target_link_uri("some-valid-url"), + Claims.ResourceLink.resource_link("some-resource-link-id"), + Claims.Roles.roles([]) + ] + + assert {:ok, %Lti_1p3.Platform.AuthorizationPayload{} = payload} = + Platform.authorize_redirect(params, user, issuer, claims) - params = - params - |> Map.drop(["scope", "nonce"]) + assert payload.redirect_uri == target_link_uri + assert payload.state == state + assert is_binary(payload.id_token) + end + + test "fails on missing oidc params" do + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() + + params = Map.drop(params, ["scope", "nonce"]) claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -106,26 +96,15 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do Claims.Roles.roles([]) ] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :invalid_oidc_params, - msg: "Invalid OIDC params. The following parameters are missing: nonce, scope", - missing_params: ["nonce", "scope"] - }} + assert {:error, %{stage: :oidc_params, reason: :invalid_oidc_params}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on incorrect oidc scope" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() - params = - params - |> Map.put("scope", "invalid_scope") + params = Map.put(params, "scope", "invalid_scope") claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -134,27 +113,16 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do Claims.Roles.roles([]) ] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :invalid_oidc_scope, - msg: "Invalid OIDC scope: invalid_scope. Scope must be 'openid'" - }} + assert {:error, %{stage: :scope, reason: :invalid_oidc_scope}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on invalid login_hint user session" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() other_user = lti_1p3_user() - - params = - params - |> Map.put("login_hint", "#{other_user.id}") + params = Map.put(params, "login_hint", "#{other_user.id}") claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -163,25 +131,15 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do Claims.Roles.roles([]) ] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :invalid_login_hint, - msg: "Login hint must be linked with an active user session" - }} + assert {:error, %{stage: :user, reason: :invalid_login_hint}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on invalid client_id" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() - params = - params - |> Map.put("client_id", "some-other-client-id") + params = Map.put(params, "client_id", "some-other-client-id") claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -190,25 +148,15 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do Claims.Roles.roles([]) ] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :client_not_registered, - msg: "No platform exists with client id 'some-other-client-id'" - }} + assert {:error, %{stage: :platform_registration, reason: :client_not_registered}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on invalid redirect_uri" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() - params = - params - |> Map.put("redirect_uri", "some-invalid_redirect-uri") + params = Map.put(params, "redirect_uri", "some-invalid_redirect-uri") claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -217,21 +165,13 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do Claims.Roles.roles([]) ] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :unauthorized_redirect_uri, - msg: "Redirect URI not authorized in requested context" - }} + assert {:error, %{stage: :redirect, reason: :unauthorized_redirect_uri}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on duplicate nonce" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() claims = [ Claims.DeploymentId.deployment_id(deployment_id), @@ -243,57 +183,55 @@ defmodule Lti_1p3.Platform.AuthorizationRedirectTest do assert {:ok, _target_link_uri, _state, _id_token} = AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) - # try again with the same nonce - assert {:error, %{reason: :invalid_nonce, msg: "Duplicate nonce"}} == + assert {:error, %{stage: :nonce, reason: :invalid_nonce, msg: "Duplicate nonce"}} = AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end test "fails on missing required claims" do - %{ - issuer: issuer, - deployment_id: _deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() + %{issuer: issuer, params: params, user: user} = generate_lti_platform_stubs() claims = [] - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :missing_required_claims, - msg: - "Missing required claims: https://purl.imsglobal.org/spec/lti/claim/deployment_id, https://purl.imsglobal.org/spec/lti/claim/target_link_uri, https://purl.imsglobal.org/spec/lti/claim/roles", - missing_claims: [ - "https://purl.imsglobal.org/spec/lti/claim/deployment_id", - "https://purl.imsglobal.org/spec/lti/claim/target_link_uri", - "https://purl.imsglobal.org/spec/lti/claim/roles" - ] - }} + assert {:error, %{stage: :claims, reason: :missing_required_claims}} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) end + end - test "fails on missing required roles claim" do - %{ - issuer: issuer, - deployment_id: deployment_id, - params: params, - user: user - } = generate_lti_platform_stubs() - - claims = [ - Claims.DeploymentId.deployment_id(deployment_id), - Claims.TargetLinkUri.target_link_uri("some-valid-url"), - Claims.ResourceLink.resource_link("some-resource-link-id") - ] - - assert AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) == - {:error, - %{ - reason: :missing_required_claims, - msg: "Missing required claims: https://purl.imsglobal.org/spec/lti/claim/roles", - missing_claims: ["https://purl.imsglobal.org/spec/lti/claim/roles"] - }} - end + test "emits stage and outcome telemetry events for platform authorization" do + handler_id = "platform-authorize-telemetry-#{System.unique_integer([:positive])}" + parent = self() + + :ok = + :telemetry.attach_many( + handler_id, + [ + [:lti_1p3, :core, :validation, :stage], + [:lti_1p3, :core, :validation, :outcome] + ], + fn event, _measurements, metadata, _config -> + send(parent, {:telemetry_event, event, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + %{issuer: issuer, deployment_id: deployment_id, params: params, user: user} = + generate_lti_platform_stubs() + + claims = [ + Claims.MessageType.message_type(:lti_resource_link_request), + Claims.DeploymentId.deployment_id(deployment_id), + Claims.TargetLinkUri.target_link_uri("some-valid-url"), + Claims.ResourceLink.resource_link("some-resource-link-id"), + Claims.Roles.roles([]) + ] + + assert {:ok, _redirect_uri, _state, _id_token} = + AuthorizationRedirect.authorize_redirect(params, user, issuer, claims) + + assert_receive {:telemetry_event, [:lti_1p3, :core, :validation, :stage], %{result: :ok}} + assert_receive {:telemetry_event, [:lti_1p3, :core, :validation, :outcome], %{result: :ok}} end def create_active_jwk(_context) do diff --git a/test/lti_1p3/platform/login_hints_test.exs b/test/lti_1p3/platform/login_hints_test.exs index 5c6c7de..177c6de 100644 --- a/test/lti_1p3/platform/login_hints_test.exs +++ b/test/lti_1p3/platform/login_hints_test.exs @@ -33,9 +33,15 @@ defmodule Lti_1p3.Platform.LoginHintsTest do assert fetched_login_hint == login_hint # fake the nonce was created a day + 1 hour ago - a_day_before = Timex.now |> Timex.subtract(Timex.Duration.from_hours(25)) + a_day_before = Timex.now() |> Timex.subtract(Timex.Duration.from_hours(25)) + Agent.update(MemoryProvider, fn state -> - %{state | login_hints: state.login_hints |> Map.put(login_hint.value, Map.put(login_hint, :inserted_at, a_day_before))} + %{ + state + | login_hints: + state.login_hints + |> Map.put(login_hint.value, Map.put(login_hint, :inserted_at, a_day_before)) + } end) # run cleanup @@ -50,5 +56,4 @@ defmodule Lti_1p3.Platform.LoginHintsTest do %{user: user} end - end diff --git a/test/lti_1p3/platform_test.exs b/test/lti_1p3/platform_test.exs index a09677f..c28d246 100644 --- a/test/lti_1p3/platform_test.exs +++ b/test/lti_1p3/platform_test.exs @@ -5,16 +5,17 @@ defmodule Lti_1p3.PlatformTest do describe "Lti_1p3 Platform" do test "should create platform instance" do - {:ok, platform} = Lti_1p3.Platform.create_platform_instance(%PlatformInstance{ - client_id: "some-client-id", - custom_params: "some-custom-params", - description: "some-description", - keyset_url: "some-keyset-url", - login_url: "some-login-url", - name: "some-name", - redirect_uris: "some-redirect-uris", - target_link_uri: "some-target-link-uri", - }) + {:ok, platform} = + Lti_1p3.Platform.create_platform_instance(%PlatformInstance{ + client_id: "some-client-id", + custom_params: "some-custom-params", + description: "some-description", + keyset_url: "some-keyset-url", + login_url: "some-login-url", + name: "some-name", + redirect_uris: "some-redirect-uris", + target_link_uri: "some-target-link-uri" + }) assert platform.id != nil assert platform.client_id == "some-client-id" @@ -26,6 +27,5 @@ defmodule Lti_1p3.PlatformTest do assert platform.redirect_uris == "some-redirect-uris" assert platform.target_link_uri == "some-target-link-uri" end - end end diff --git a/test/lti_1p3/provider_contracts_test.exs b/test/lti_1p3/provider_contracts_test.exs new file mode 100644 index 0000000..b9120e9 --- /dev/null +++ b/test/lti_1p3/provider_contracts_test.exs @@ -0,0 +1,29 @@ +defmodule Lti_1p3.ProviderContractsTest do + use Lti_1p3.Test.TestCase + + alias Lti_1p3.DataProviderError + + test "tool provider get_jwk_by_registration returns tuple contract" do + jwk = jwk_fixture() + registration = registration_fixture(%{tool_jwk_id: jwk.id}) + + assert {:ok, returned_jwk} = Lti_1p3.Config.provider!().get_jwk_by_registration(registration) + assert returned_jwk.id == jwk.id + end + + test "tool provider get_jwk_by_registration returns not_found error when missing" do + registration = registration_fixture(%{tool_jwk_id: -1}) + + assert {:error, %DataProviderError{reason: :not_found}} = + Lti_1p3.Config.provider!().get_jwk_by_registration(registration) + end + + test "tool provider get_registration_deployment returns tuple with nils when absent" do + assert {nil, nil} = + Lti_1p3.Config.provider!().get_registration_deployment( + "missing", + "missing", + "missing" + ) + end +end diff --git a/test/lti_1p3/tool/deep_linking_test.exs b/test/lti_1p3/tool/deep_linking_test.exs new file mode 100644 index 0000000..ae50652 --- /dev/null +++ b/test/lti_1p3/tool/deep_linking_test.exs @@ -0,0 +1,186 @@ +defmodule Lti_1p3.Tool.DeepLinkingTest do + use Lti_1p3.Test.TestCase + + alias Lti_1p3.DeepLinking.ClaimKeys + alias Lti_1p3.Tool + alias Lti_1p3.Tool.DeepLinking + + describe "validate_deep_linking_request/1" do + test "returns typed request and settings" do + claims = deep_linking_claims() + + assert {:ok, request} = Tool.validate_deep_linking_request(claims) + assert request.message_type == "LtiDeepLinkingRequest" + assert request.settings.deep_link_return_url == "https://tool.example.com/deep_link_return" + assert request.settings.accept_types == ["ltiResourceLink", "link"] + end + + test "returns structured error for malformed settings" do + claims = + deep_linking_claims() + |> put_in([ClaimKeys.key(:deep_linking_settings), "accept_types"], nil) + + assert {:error, %{reason: :invalid_deep_linking_settings, stage: :request}} = + Tool.validate_deep_linking_request(claims) + end + end + + describe "launch validation dispatch integration" do + test "fails message validation when deep-linking settings are incomplete" do + claims = + deep_linking_claims() + |> put_in([ClaimKeys.key(:deep_linking_settings), "accept_types"], []) + + assert {:error, %{reason: :invalid_deep_linking_settings}} = + Lti_1p3.Tool.MessageDispatch.validate(claims) + end + end + + describe "deep_linking_content_item/2" do + test "builds typed content items" do + assert {:ok, item} = + Tool.deep_linking_content_item(:lti_resource_link, %{ + "url" => "https://tool.example.com/resource/1", + "title" => "Example Item", + "lineItem" => %{"scoreMaximum" => 100} + }) + + assert item.type == "ltiResourceLink" + assert item.line_item["scoreMaximum"] == 100 + end + + test "rejects unsupported subtype" do + assert {:error, %{reason: :unsupported_content_item_type, stage: :content_item}} = + Tool.deep_linking_content_item("unknown", %{}) + end + end + + describe "build_deep_linking_response/3" do + test "builds signed response and includes correlated data claim" do + jwk = jwk_fixture() + claims = deep_linking_claims() + assert {:ok, request} = Tool.validate_deep_linking_request(claims) + + assert {:ok, item} = + Tool.deep_linking_content_item(:link, %{ + "url" => "https://tool.example.com/activity", + "title" => "Activity" + }) + + assert {:ok, %{jwt: jwt, return_url: "https://tool.example.com/deep_link_return"}} = + Tool.build_deep_linking_response(request, [item]) + + assert {:ok, token_claims} = verify_signature(jwt, jwk) + assert token_claims["iss"] == "12345" + assert token_claims["aud"] == "https://lti-ri.imsglobal.org" + assert token_claims[ClaimKeys.key(:message_type)] == "LtiDeepLinkingResponse" + assert token_claims[ClaimKeys.key(:data)] == "opaque-correlation" + assert [returned_item] = token_claims[ClaimKeys.key(:content_items)] + assert returned_item["type"] == "link" + end + + test "supports compatibility filter strategy" do + _jwk = jwk_fixture() + + claims = + deep_linking_claims() + |> put_in([ClaimKeys.key(:deep_linking_settings), "accept_types"], ["ltiResourceLink"]) + + assert {:ok, request} = Tool.validate_deep_linking_request(claims) + + assert {:ok, accepted_item} = + DeepLinking.content_item(:lti_resource_link, %{ + "url" => "https://tool.example.com/resource/1" + }) + + assert {:ok, dropped_item} = + DeepLinking.content_item(:link, %{"url" => "https://tool.example.com/link/1"}) + + assert {:ok, %{jwt: jwt}} = + Tool.build_deep_linking_response(request, [accepted_item, dropped_item], + unsupported_type_strategy: :filter_unsupported + ) + + assert {:ok, token_claims} = Joken.peek_claims(jwt) + assert [only_item] = token_claims[ClaimKeys.key(:content_items)] + assert only_item["type"] == "ltiResourceLink" + end + + test "returns structured error when all items are filtered out" do + _jwk = jwk_fixture() + + claims = + deep_linking_claims() + |> put_in([ClaimKeys.key(:deep_linking_settings), "accept_types"], ["ltiResourceLink"]) + + assert {:ok, request} = Tool.validate_deep_linking_request(claims) + + assert {:ok, unsupported_item} = + Tool.deep_linking_content_item(:link, %{"url" => "https://tool.example.com/link"}) + + assert {:error, %{reason: :no_supported_content_items, stage: :response}} = + Tool.build_deep_linking_response(request, [unsupported_item], + unsupported_type_strategy: :filter_unsupported + ) + end + end + + test "emits deep-linking telemetry events for request and response outcomes" do + _jwk = jwk_fixture() + + handler_id = "tool-deep-linking-telemetry-#{System.unique_integer([:positive])}" + parent = self() + + :ok = + :telemetry.attach_many( + handler_id, + [ + [:lti_1p3, :tool, :deep_linking, :request], + [:lti_1p3, :tool, :deep_linking, :response] + ], + fn event, _measurements, metadata, _config -> + send(parent, {:telemetry_event, event, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + claims = deep_linking_claims() + assert {:ok, request} = Tool.validate_deep_linking_request(claims) + + assert {:ok, item} = + Tool.deep_linking_content_item(:link, %{"url" => "https://tool.example.com/activity"}) + + assert {:ok, _payload} = Tool.build_deep_linking_response(request, [item]) + + assert_receive {:telemetry_event, [:lti_1p3, :tool, :deep_linking, :request], %{result: :ok}} + + assert_receive {:telemetry_event, [:lti_1p3, :tool, :deep_linking, :response], %{result: :ok}} + end + + defp deep_linking_claims do + all_default_claims() + |> Map.put(ClaimKeys.key(:message_type), "LtiDeepLinkingRequest") + |> Map.put( + ClaimKeys.key(:deep_linking_settings), + %{ + "deep_link_return_url" => "https://tool.example.com/deep_link_return", + "accept_types" => ["ltiResourceLink", "link"], + "accept_presentation_document_targets" => ["iframe", "window"], + "data" => "opaque-correlation" + } + ) + end + + defp verify_signature(jwt, jwk) do + public_key = jwk.pem |> JOSE.JWK.from_pem() |> JOSE.JWK.to_public() + {_kty, key_map} = JOSE.JWK.to_map(public_key) + signer = Joken.Signer.create("RS256", key_map) + + case Joken.verify_and_validate(%{}, jwt, signer) do + {:ok, claims} -> {:ok, claims} + {:error, _} = error -> error + end + end +end diff --git a/test/lti_1p3/tool/launch_validation_test.exs b/test/lti_1p3/tool/launch_validation_test.exs index 99eee2f..418fe8b 100644 --- a/test/lti_1p3/tool/launch_validation_test.exs +++ b/test/lti_1p3/tool/launch_validation_test.exs @@ -4,43 +4,34 @@ defmodule Lti_1p3.Tool.LaunchValidationTest do import Mox alias Lti_1p3.Test.MockHTTPoison + alias Lti_1p3.Tool alias Lti_1p3.Tool.LaunchValidation - # Make sure mocks are verified when the test exits setup :verify_on_exit! setup :set_mox_from_context setup do - # Start the key provider supervisor for each test {:ok, supervisor_pid} = Lti_1p3.KeyProviderSupervisor.start_link( key_provider: Lti_1p3.KeyProviders.MemoryKeyProvider, - # Disable automatic refresh for tests refresh_interval: 0 ) - # Get the child process (MemoryKeyProvider) and allow it to use the mock - [ - {Lti_1p3.KeyProviders.MemoryKeyProvider, child_pid, :worker, - [Lti_1p3.KeyProviders.MemoryKeyProvider]} - ] = + [{Lti_1p3.KeyProviders.MemoryKeyProvider, child_pid, :worker, _}] = Supervisor.which_children(supervisor_pid) Mox.allow(MockHTTPoison, self(), child_pid) - # Clear key cache before each test to ensure clean state Lti_1p3.KeyProviders.MemoryKeyProvider.clear_cache() on_exit(fn -> - if Process.alive?(supervisor_pid) do - Process.exit(supervisor_pid, :normal) - end + if Process.alive?(supervisor_pid), do: Process.exit(supervisor_pid, :normal) end) :ok end - describe "launch validation" do + describe "validate_launch/3" do setup do jwk = jwk_fixture() registration = registration_fixture(%{tool_jwk_id: jwk.id}) @@ -49,381 +40,268 @@ defmodule Lti_1p3.Tool.LaunchValidationTest do _deployment = deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - state = "some-state" - - [jwk: jwk, registration: registration, deployment_id: deployment_id, state: state] + %{jwk: jwk, registration: registration, deployment_id: deployment_id, state: "some-state"} end - test "passes validation for a valid launch request and caches lti params", %{ + test "returns normalized launch struct for valid launch", %{ jwk: jwk, deployment_id: deployment_id, state: state } do claims = all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) + + assert {:ok, %Lti_1p3.Tool.Launch{} = launch} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) - assert {:ok, _lti_params} = LaunchValidation.validate(params, state) + assert launch.message_type == "LtiResourceLinkRequest" + assert launch.deployment_id == deployment_id + assert launch.raw_claims == nil + assert launch.registration.id end - test "passes validation when aud claim is a list", %{ + test "can include raw claims in launch response", %{ jwk: jwk, deployment_id: deployment_id, state: state } do claims = all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> Map.put("aud", ["12345"]) + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - assert {:ok, _lti_params} = LaunchValidation.validate(params, state) + assert {:ok, %Lti_1p3.Tool.Launch{raw_claims: raw_claims}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state, + raw_claims: true + ) + + assert raw_claims["iss"] == claims["iss"] end - test "passes validation when JWK is not Base64URL encoded", %{ + test "supports deep-linking request validation scaffolding", %{ jwk: jwk, deployment_id: deployment_id, state: state } do claims = all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) + |> Map.put( + "https://purl.imsglobal.org/spec/lti/claim/message_type", + "LtiDeepLinkingRequest" + ) + |> Map.put("https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings", %{ + "deep_link_return_url" => "https://tool.example.com/return", + "accept_types" => ["ltiResourceLink"], + "accept_presentation_document_targets" => ["iframe"] + }) id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - transform_fn = fn map -> - Map.update!(map, "n", &convert_to_base64_encoding(&1)) |> Map.put("exp", 12345) - end - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk, transform: transform_fn) end) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - assert {:ok, _lti_params} = LaunchValidation.validate(params, state) + assert {:ok, %Lti_1p3.Tool.Launch{message_type: "LtiDeepLinkingRequest"}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) end - end - - test "fails validation on missing oidc state" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "some-state" - session_state = nil - - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - assert LaunchValidation.validate(params, session_state) == - {:error, - %{ - reason: :invalid_oidc_state, - msg: - "State from session is missing. Make sure cookies are enabled and configured correctly" - }} - end - - test "fails validation on invalid oidc state" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "doesn't" - session_state = "match" - - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - assert LaunchValidation.validate(params, session_state) == - {:error, - %{ - reason: :invalid_oidc_state, - msg: "State from OIDC request does not match session" - }} - end - - test "fails validation if registration doesn't exist for client id" do - jwk = jwk_fixture() - - registration = - registration_fixture(%{ - issuer: "some issuer", - client_id: "some client_id", - key_set_url: "some key_set_url", - auth_token_url: "some auth_token_url", - auth_login_url: "some auth_login_url", - auth_server: "some auth_aud", - tool_jwk_id: jwk.id - }) - - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "some-state" - session_state = state - - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - assert LaunchValidation.validate(params, session_state) == - {:error, - %{ - reason: :invalid_registration, - msg: - "Registration with issuer \"https://lti-ri.imsglobal.org\" and client id \"12345\" not found", - issuer: "https://lti-ri.imsglobal.org", - client_id: "12345" - }} - end - - test "fails validation on missing id_token" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - state = "some-state" - session_state = state - id_token = nil - params = %{"state" => state, "id_token" => id_token} - - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :missing_param, msg: "Missing id_token"}} - end - - test "fails validation on malformed id_token" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "some-state" - session_state = state - id_token = "malformed" - params = %{"state" => state, "id_token" => id_token} - - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :token_malformed, msg: "Invalid JWT"}} - end - - test "fails validation on invalid signature" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "some-state" - session_state = state + test "fails with explicit state stage on mismatched state", %{ + jwk: jwk, + deployment_id: deployment_id + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) + id_token = generate_id_token(jwk, jwk.kid, claims) - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + assert {:error, %{stage: :state, reason: :invalid_oidc_state}} = + Tool.validate_launch( + %{"state" => "request-state", "id_token" => id_token}, + "session-state" + ) + end - different_jwk = jwk_fixture(%{kid: jwk.kid}) + test "fails with explicit jwt stage for malformed token", %{state: state} do + assert {:error, %{stage: :registration, reason: :token_malformed}} = + Tool.validate_launch(%{"state" => state, "id_token" => "malformed"}, state) + end - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(different_jwk) end) + test "fails with explicit jwt stage for missing kid", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :signature_error, msg: "Invalid JWT"}} - end + signer = Joken.Signer.create("RS256", %{"pem" => jwk.pem}) + {:ok, claims} = Joken.generate_claims(%{}, claims) + id_token = Joken.generate_and_sign!(%{}, claims, signer) - test "fails validation on expired exp" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" + assert {:error, %{stage: :jwt, reason: :missing_kid}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) + test "fails with explicit jwt stage for unsupported algorithm", %{ + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - state = "some-state" - session_state = state + signer = Joken.Signer.create("HS256", "secret") + {:ok, claims} = Joken.generate_claims(%{}, claims) + id_token = Joken.generate_and_sign!(%{}, claims, signer) - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in( - ["exp"], - Timex.now() |> Timex.subtract(Timex.Duration.from_minutes(5)) |> Timex.to_unix() - ) + assert {:error, %{stage: :jwt, reason: :invalid_jwt_alg}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + test "fails with explicit jwt stage when key resolution fails", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + id_token = generate_id_token(jwk, "unknown-kid", claims) - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :invalid_jwt_timestamp, msg: "JWT exp is expired"}} - end + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - test "fails validation on token iat invalid" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" + assert {:error, %{stage: :jwt, reason: :key_not_found}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) + test "fails with explicit jwt stage for invalid audience", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("aud", ["12345", "different-client-id"]) + |> Map.delete("azp") + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - state = "some-state" - session_state = state + id_token = generate_id_token(jwk, jwk.kid, claims) - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in( - ["iat"], - Timex.now() |> Timex.add(Timex.Duration.from_minutes(5)) |> Timex.to_unix() - ) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + assert {:error, %{stage: :jwt, reason: :invalid_audience}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + test "fails with explicit timestamps stage for expired token", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) + |> Map.put( + "exp", + Timex.now() |> Timex.subtract(Timex.Duration.from_minutes(5)) |> Timex.to_unix() + ) - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :invalid_jwt_timestamp, msg: "JWT iat is invalid"}} - end + id_token = generate_id_token(jwk, jwk.kid, claims) - test "fails validation on both expired exp and iat invalid" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) + assert {:error, + %{stage: :timestamps, reason: :invalid_jwt_timestamp, msg: "JWT exp is expired"}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - state = "some-state" - session_state = state + test "fails with explicit nonce stage for duplicate nonce", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) + |> Map.put("nonce", "duplicate nonce") - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in( - ["exp"], - Timex.now() |> Timex.subtract(Timex.Duration.from_minutes(5)) |> Timex.to_unix() - ) - |> put_in( - ["iat"], - Timex.now() |> Timex.add(Timex.Duration.from_minutes(5)) |> Timex.to_unix() - ) + id_token = generate_id_token(jwk, jwk.kid, claims) - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + assert {:ok, _launch} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :invalid_jwt_timestamp, msg: "JWT exp and iat are invalid"}} - end + assert {:error, %{stage: :nonce, reason: :invalid_nonce, msg: "Duplicate nonce"}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - test "fails validation on duplicate nonce" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" + test "fails with explicit message stage for unsupported message type", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/message_type", "InvalidMessageType") - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) + id_token = generate_id_token(jwk, jwk.kid, claims) - state = "some-state" - session_state = state + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in(["nonce"], "duplicate nonce") + assert {:error, %{stage: :message, reason: :invalid_message_type}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) + end - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + test "legacy LaunchValidation module delegates to the same pipeline", %{ + jwk: jwk, + deployment_id: deployment_id, + state: state + } do + claims = + all_default_claims() + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + id_token = generate_id_token(jwk, jwk.kid, claims) - # passes on first attempt with a given nonce - assert {:ok, _jwt_body} = LaunchValidation.validate(params, session_state) + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - # fails on second attempt with a duplicate nonce (no HTTP call needed due to caching) - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :invalid_nonce, msg: "Duplicate nonce"}} + assert {:ok, %Lti_1p3.Tool.Launch{}} = + LaunchValidation.validate(%{"state" => state, "id_token" => id_token}, state) + end end - test "fails validation if deployment doesn't exist" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - - state = "some-state" - session_state = state - - claims = - all_default_claims() - |> put_in( - ["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], - "invalid_deployment_id" + test "emits stage and outcome telemetry events for tool launch" do + handler_id = "tool-launch-telemetry-#{System.unique_integer([:positive])}" + + parent = self() + + :ok = + :telemetry.attach_many( + handler_id, + [ + [:lti_1p3, :core, :validation, :stage], + [:lti_1p3, :core, :validation, :outcome] + ], + fn event, _measurements, metadata, _config -> + send(parent, {:telemetry_event, event, metadata}) + end, + nil ) - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) - - assert LaunchValidation.validate(params, session_state) == - {:error, - %{ - reason: :invalid_deployment, - msg: "Deployment with id \"invalid_deployment_id\" not found", - registration_id: registration.id, - deployment_id: "invalid_deployment_id" - }} - end + on_exit(fn -> :telemetry.detach(handler_id) end) - test "fails validation on missing message type" do jwk = jwk_fixture() registration = registration_fixture(%{tool_jwk_id: jwk.id}) deployment_id = "1" @@ -431,59 +309,21 @@ defmodule Lti_1p3.Tool.LaunchValidationTest do _deployment = deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - state = "some-state" - session_state = state - claims = all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/message_type"], nil) + |> Map.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", deployment_id) id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} - - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) - - assert LaunchValidation.validate(params, session_state) == - {:error, %{reason: :invalid_message_type, msg: "Missing message type"}} - end - - test "fails validation on invalid message type" do - jwk = jwk_fixture() - registration = registration_fixture(%{tool_jwk_id: jwk.id}) - deployment_id = "1" - - _deployment = - deployment_fixture(%{deployment_id: deployment_id, registration_id: registration.id}) - state = "some-state" - session_state = state - claims = - all_default_claims() - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/deployment_id"], deployment_id) - |> put_in(["https://purl.imsglobal.org/spec/lti/claim/message_type"], "InvalidMessageType") + expect(MockHTTPoison, :get, fn _url -> mock_get_jwk_keys(jwk) end) - id_token = generate_id_token(jwk, jwk.kid, claims) - params = %{"state" => state, "id_token" => id_token} + assert {:ok, %Lti_1p3.Tool.Launch{}} = + Tool.validate_launch(%{"state" => state, "id_token" => id_token}, state) - MockHTTPoison - |> expect(:get, fn _url -> mock_get_jwk_keys(jwk) end) + assert_receive {:telemetry_event, [:lti_1p3, :core, :validation, :stage], + %{stage: :state, result: :ok}} - assert LaunchValidation.validate(params, session_state) == - {:error, - %{ - reason: :invalid_message_type, - msg: "Invalid or unsupported message type \"InvalidMessageType\"" - }} - end - - defp convert_to_base64_encoding(str) do - String.replace(str, ["-", "_"], fn - "-" -> "+" - "_" -> "/" - c -> c - end) + assert_receive {:telemetry_event, [:lti_1p3, :core, :validation, :outcome], %{result: :ok}} end end diff --git a/test/lti_1p3/tool/services/ags_test.exs b/test/lti_1p3/tool/services/ags_test.exs index ed6472c..a12942e 100644 --- a/test/lti_1p3/tool/services/ags_test.exs +++ b/test/lti_1p3/tool/services/ags_test.exs @@ -117,33 +117,33 @@ defmodule Lti_1p3.Tool.Services.AGSTest do refute AGS.get_line_items_url(%{}) refute AGS.get_line_items_url(%{}, %{ - line_items_service_domain: @lti_items_service_domain - }) + line_items_service_domain: @lti_items_service_domain + }) end test "returns the url from line items claim when no registration present" do assert AGS.get_line_items_url(@lti_params) == - @line_items_url + @line_items_url end test "returns the url from line items claim when registration present but not line_items_service_domain" do assert AGS.get_line_items_url(@lti_params, %{ - auth_server: "some auth_server" - }) == @line_items_url + auth_server: "some auth_server" + }) == @line_items_url assert AGS.get_line_items_url(@lti_params, %{ - line_items_service_domain: "" - }) == @line_items_url + line_items_service_domain: "" + }) == @line_items_url assert AGS.get_line_items_url(@lti_params, %{ - line_items_service_domain: nil - }) == @line_items_url + line_items_service_domain: nil + }) == @line_items_url end test "returns the url from line items claim with the registration line_items_service_domain" do assert AGS.get_line_items_url(@lti_params, %{ - line_items_service_domain: @lti_items_service_domain - }) == "https://registration.example.com/api/lti/courses/8/line_items" + line_items_service_domain: @lti_items_service_domain + }) == "https://registration.example.com/api/lti/courses/8/line_items" end end @@ -245,9 +245,9 @@ defmodule Lti_1p3.Tool.Services.AGSTest do } do expect(MockHTTPoison, :post, fn _url, _body, headers -> assert [ - {"Content-Type", "application/vnd.ims.lis.v1.score+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Content-Type", "application/vnd.ims.lis.v1.score+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: ""}} end) @@ -260,10 +260,10 @@ defmodule Lti_1p3.Tool.Services.AGSTest do } do expect(MockHTTPoison, :get, fn _url, headers -> assert [ - {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, - {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, + {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "[]"}} end) @@ -281,10 +281,10 @@ defmodule Lti_1p3.Tool.Services.AGSTest do } do expect(MockHTTPoison, :post, fn _url, _body, headers -> assert [ - {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, - {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, + {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "{}"}} end) @@ -305,10 +305,10 @@ defmodule Lti_1p3.Tool.Services.AGSTest do } do expect(MockHTTPoison, :put, fn _url, _body, headers -> assert [ - {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, - {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, + {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "{}"}} end) @@ -328,20 +328,20 @@ defmodule Lti_1p3.Tool.Services.AGSTest do } do expect(MockHTTPoison, :post, fn _url, _body, headers -> assert [ - {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, - {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, + {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "{}"}} end) expect(MockHTTPoison, :get, fn _url, headers -> assert [ - {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, - {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, - {"Authorization", "Bearer fake_token"} - ] == headers + {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"}, + {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}, + {"Authorization", "Bearer fake_token"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "[]"}} end) @@ -440,7 +440,8 @@ defmodule Lti_1p3.Tool.Services.AGSTest do end) expect(MockHTTPoison, :get, fn url, _headers -> - assert "#{line_item_id_with_params.id}&resource_id=#{line_item_id_with_params.resourceId}&limit=1" == url + assert "#{line_item_id_with_params.id}&resource_id=#{line_item_id_with_params.resourceId}&limit=1" == + url {:ok, %HTTPoison.Response{status_code: 200, body: "[]"}} end) @@ -491,12 +492,13 @@ defmodule Lti_1p3.Tool.Services.AGSTest do maximum_score_provider = fn -> 1.0 end - {:ok, %{ - score: score, - line_item: line_item, - access_token: access_token, - line_item_id_with_params: line_item_id_with_params, - maximum_score_provider: maximum_score_provider - }} + {:ok, + %{ + score: score, + line_item: line_item, + access_token: access_token, + line_item_id_with_params: line_item_id_with_params, + maximum_score_provider: maximum_score_provider + }} end end diff --git a/test/lti_1p3/tool/services/nrps_test.exs b/test/lti_1p3/tool/services/nrps_test.exs index e16324e..e78a776 100644 --- a/test/lti_1p3/tool/services/nrps_test.exs +++ b/test/lti_1p3/tool/services/nrps_test.exs @@ -108,10 +108,10 @@ defmodule Lti_1p3.Tool.Services.NRPSTest do } do expect(MockHTTPoison, :get, fn _url, headers -> assert [ - {"Content-Type", "application/json"}, - {"Authorization", "Bearer fake_token"}, - {"Accept", "application/vnd.ims.lti-nrps.v2.membershipcontainer+json"} - ] == headers + {"Content-Type", "application/json"}, + {"Authorization", "Bearer fake_token"}, + {"Accept", "application/vnd.ims.lti-nrps.v2.membershipcontainer+json"} + ] == headers {:ok, %HTTPoison.Response{status_code: 200, body: "{\"members\": []}"}} end) diff --git a/test/lti_1p3/tool_test.exs b/test/lti_1p3/tool_test.exs index 1c5d5cf..52df9b6 100644 --- a/test/lti_1p3/tool_test.exs +++ b/test/lti_1p3/tool_test.exs @@ -61,5 +61,32 @@ defmodule Lti_1p3.ToolTest do assert Lti_1p3.Tool.get_registration_deployment(issuer, client_id, deployment_id) == {registration, deployment} end + + test "login_redirect returns normalized payload" do + jwk = jwk_fixture() + + registration_fixture(%{ + issuer: "https://lti-ri.imsglobal.org", + client_id: "12345", + key_set_url: "some key_set_url", + auth_token_url: "some auth_token_url", + auth_login_url: "https://platform.example.com/oidc", + auth_server: "some auth_aud", + tool_jwk_id: jwk.id + }) + + params = %{ + "iss" => "https://lti-ri.imsglobal.org", + "client_id" => "12345", + "login_hint" => "login-hint", + "target_link_uri" => "https://tool.example.com/launch" + } + + assert {:ok, %{state: state, redirect_url: redirect_url}} = + Lti_1p3.Tool.login_redirect(params) + + assert is_binary(state) + assert String.starts_with?(redirect_url, "https://platform.example.com/oidc?") + end end end