Current section
Files
Jump to
Current section
Files
usage-rules.md
# Ltix Usage Rules
Ltix is an Elixir library for building LTI 1.3 tool applications. It handles the OIDC
launch flow, JWT verification, claim parsing, and Advantage Services (grading, roster,
deep linking).
## Two-Function API
The entire OIDC launch flow is two functions.
1. `Ltix.handle_login(params, redirect_uri, opts)` - called when the platform sends the
user to your login endpoint. Returns `{:ok, %{redirect_uri: url, state: state}}`. Store
`state` in the session, redirect the user to `redirect_uri`.
2. `Ltix.handle_callback(params, state, opts)` - called when the platform POSTs back to
your launch endpoint. Returns `{:ok, %LaunchContext{}}` with parsed claims, registration,
and deployment.
- **Never** call internal OIDC modules directly (`Ltix.OIDC.*`, `Ltix.JWT.Token`). Use
the two public functions, or their raising variants `handle_login!/3` and
`handle_callback!/3`.
- `handle_login/3` takes `:storage_adapter` and `:storage_adapter_options`. `handle_callback/3` takes `:storage_adapter`, `:storage_adapter_options`, `:allow_anonymous`, `:max_token_age`, `:local_network_access`, `:req_options`, `:key_set`, `:claim_parsers`, `:role_parsers`, and `:clock_skew`. Each option except `:storage_adapter_options` and `:clock_skew` overrides its application configuration for that call. Unknown or invalid options raise `Ltix.Errors.Invalid.InvalidOption`.
## Required Configuration
Every app must configure a storage adapter.
```elixir
config :ltix, storage_adapter: MyApp.Lti.StorageAdapter
```
The storage adapter module must implement the `Ltix.StorageAdapter` behaviour (3 required callbacks).
**Never** use `Ltix.Test.StorageAdapter` outside of tests.
Phoenix apps typically group LTI-related modules under a context module like `MyApp.Lti`
(e.g., `MyApp.Lti.StorageAdapter`, `MyApp.Lti.Registration`). Ask the user about their
preferred structure before creating modules.
## Registrations & Deployments
- Construct with `Registration.new/1` and `Deployment.new/1` (validated), or their raising
variants `new!/1`. `Registration.new/1` takes a keyword list or map and returns
`Ltix.Errors.Invalid.InvalidField` for unknown, missing, or invalid fields. **Never**
build `%Registration{}` or `%Deployment{}` structs directly. Always use `new/1`.
- Storage adapters return structs implementing `Ltix.Registerable` and `Ltix.Deployable`
protocols. These can be your own Ecto schemas. The library extracts the `Registration`
and `Deployment` it needs internally. `%Registration{}` and `%Deployment{}` implement
both protocols, so returning them directly still works.
- The original struct from the storage adapter is preserved in `LaunchContext`, so you can
access your own fields (DB IDs, tenant info) without extra queries after launch.
- Issuer must be an HTTPS URL with no query or fragment components.
- All endpoints (`auth_endpoint`, `jwks_uri`, `token_endpoint`) must be HTTPS. HTTP is accepted
only for loopback and local hosts such as `http://localhost:8080`.
- Platform requests reach only public addresses. For a platform on a private network or on
the same machine, set `local_network_access: :local` or `:loopback` in `config :ltix` or on
the call. Otherwise the request returns `Ltix.Errors.Security.LocalNetworkAccessDenied`.
- `client_id` is a non-empty string.
- `deployment_id` must be non-empty, at most 255 characters, ASCII-only, and is case-sensitive.
## StorageAdapter Callbacks
These are the 3 required callbacks.
1. **`get_registration(issuer, client_id, opts)`** - `client_id` can be `nil` when the platform
omits it from the login request. You must handle both cases.
2. **`get_deployment(registration, deployment_id, opts)`** - called after JWT verification.
`deployment_id` is case-sensitive. `registration` is the struct your `get_registration/3`
returned, as it is for every callback that takes one.
3. **`validate_nonce(nonce, registration, expires_at, opts)`** - record the nonce as used, called
after the ID token is verified. Must prevent replay attacks. Use an atomic
`INSERT ... ON CONFLICT DO NOTHING` to avoid race conditions, and keep the record until
`expires_at`.
The callbacks return these types.
- `get_registration/3` returns `{:ok, Registerable.t()} | {:error, :not_found}`, with any
struct implementing `Ltix.Registerable` (including `%Registration{}` itself)
- `get_deployment/3` returns `{:ok, Deployable.t()} | {:error, :not_found}`, with any
struct implementing `Ltix.Deployable` (including `%Deployment{}` itself)
- `validate_nonce/4` returns `:ok | {:error, :nonce_already_used}`
Any other `{:error, reason}` from a callback fails the login or launch with `Ltix.Errors.Unknown.StorageError`.
Two optional callbacks sign for the tool. Implement them to call Advantage services or build
deep linking responses.
- **`key_id(registration, opts)`** - `{:ok, kid}` for the key that signs for the registration.
- **`sign(registration, key_id, message, opts)`** - `{:ok, signature}` with the RS256 signature of
`message` as raw bytes. `Ltix.JWK.sign(jwk, message)` returns this for a key the app holds.
`Ltix.Registration` has no key field. Ltix never stores the tool's private key.
**Nonce is used on callback.** `handle_callback/3` records the nonce as used as part of
validation. If the callback fails for a non-security reason (e.g., `DeploymentNotFound`),
you cannot retry `handle_callback/3` with the same params, because the nonce is already
used. Handle recoverable errors (like auto-creating a deployment) in the storage adapter
itself.
## Error Handling
Errors use the Splode framework with three classes.
- `:invalid` - bad input, missing claims, malformed JWT (HTTP 400)
- `:security` - bad signature, expired token, nonce replay (HTTP 401)
- `:unknown` - network errors, unexpected failures (HTTP 500)
Most errors have a `.spec_ref` field that references the violated spec passage. Use
`Ltix.Errors.status_code/1` to get the HTTP status for any error (invalid=400,
security=401, unknown=500). When Plug is a dependency, all errors also implement
`Plug.Exception`, so Phoenix error views use the correct status automatically.
A single failure is returned directly. Several simultaneous failures are grouped in their class, such as `%Ltix.Errors.Invalid{errors: [...]}`.
Match on specific error modules for targeted handling, or match on `.class` for broad
categories.
```elixir
case Ltix.handle_callback(params, state) do
{:ok, context} ->
render(conn, :launch, context: context)
{:error, %Ltix.Errors.Invalid.DeploymentNotFound{} = error} ->
render(conn, :onboard, deployment_id: error.deployment_id)
{:error, error} ->
conn
|> put_status(Ltix.Errors.status_code(error))
|> render(:error, class: error.class, message: Exception.message(error))
end
```
## Roles
- `Role.instructor?/1`, `Role.learner?/1`, `Role.teaching_assistant?/1` etc. check
**context roles only** (course-level).
- `Role.administrator?/1` checks the **institution** administrator role. Use `Role.context_administrator?/1` for course administrators.
- For other institution or system roles, use `Role.has_role?/4`.
- Platform granularity varies.
- Canvas sends sub-roles (e.g., `Instructor#TeachingAssistant`)
- Moodle sends only `Instructor` or `Learner` (no sub-roles)
- Others vary by admin configuration
- **Design for the roles every platform sends.** Only reliably expect Instructor vs Learner
across all platforms.
## Cross-Origin & Iframe Considerations
LTI launches are cross-origin POSTs (platform domain to tool domain), often inside an
iframe.
- Phoenix defaults to `SameSite=Lax`, which blocks cross-origin POST cookies.
Set `same_site: "None"`, `secure: true`, and `extra: "Partitioned"` in your endpoint
session config. `Partitioned` keeps the cookie working in browsers that block
third-party cookies.
- HTTPS is mandatory in both production and development (`mix phx.gen.cert` for dev certs).
- LTI tools are typically embedded in an iframe by the platform. Set a
`Content-Security-Policy` header (`frame-ancestors 'self' *`) on LTI routes to allow
this. The platform domains should be scoped down in production.
- The login endpoint should accept both GET and POST, because the LTI spec allows
either.
- **Never store `%LaunchContext{}` in the session.** It holds the user's identity and
course data, and session cookies are signed, not encrypted. Extract only the fields
you need (e.g., `subject`, `roles`, `context.id`).
## Service Availability
Advantage Service endpoints (grading, roster, deep linking) are optional per-launch. The
platform decides what to include based on tool configuration. **Always** check for
`ServiceNotAvailable` before assuming a service is present.
```elixir
case Ltix.GradeService.authenticate(context) do
{:ok, client} -> Ltix.GradeService.post_score(client, score)
{:error, %Ltix.Errors.Invalid.ServiceNotAvailable{}} -> :ok
{:error, error} -> {:error, error}
end
```
## Optional Configuration
- `:allow_anonymous` - allow launches without a `sub` claim (defaults to `false`)
- `:json_library` - auto-detected (`JSON` on Elixir 1.18+/OTP 27+, else `Jason`)
- `:req_options` - default HTTP options for all outgoing requests
- `:key_set` - name of the `Ltix.JWT.KeySet` to get platform public keys from
(defaults to `Ltix.JWT.KeySet`). It must be started in your supervision tree, as
`children = [Ltix.JWT.KeySet]`, or `{Ltix.JWT.KeySet, name: MyApp.LtiKeys}` with
`config :ltix, key_set: MyApp.LtiKeys`. Pass `key_set:` to `handle_callback/3` to
override it per call.
- Custom claim/role parsers under the `Ltix.LaunchClaims` config key as `:claim_parsers`
and `:role_parsers`. Pass the same options to `handle_callback/3` to override them for
one call.
## Telemetry
Ltix emits `:telemetry` events across the OIDC flow, Advantage services, OAuth, JWKS
cache, and deep linking. All span events follow the `[:ltix, <component>, ...]` naming
convention with `:start`, `:stop`, and `:exception` suffixes.
These are the key event prefixes.
- `[:ltix, :login, ...]` and `[:ltix, :callback, ...]` - OIDC launch flow
- `[:ltix, :grade_service, <action>, ...]` - grade service operations
- `[:ltix, :memberships_service, <action>, ...]` - memberships service operations
- `[:ltix, :deep_linking, :build_response, ...]` - deep linking response building
- `[:ltix, :oauth, :authenticate, ...]` and `[:ltix, :oauth, :refresh, ...]` - OAuth token acquisition
- `[:ltix, :jwks, :cache_hit]` and `[:ltix, :jwks, :cache_miss]` - single events (not spans)
See the [telemetry guide](https://ltix.hexdocs.pm/telemetry.md) for full metadata details.
## Finding Documentation
Use the `usage_rules.docs` and `usage_rules.search_docs` mix tasks.
```
mix usage_rules.docs Ltix.GradeService
mix usage_rules.docs Ltix.GradeService.Score.new/1
mix usage_rules.search_docs "line items" -p ltix
mix usage_rules.search_docs "storage adapter" -p ltix
```
Hexdocs can serve raw markdown, which is much easier to read than HTML. Fetch module
docs and guides directly.
```
# Module docs
https://ltix.hexdocs.pm/Ltix.GradeService.md
https://ltix.hexdocs.pm/Ltix.StorageAdapter.md
# Guides
https://ltix.hexdocs.pm/getting-started.md
https://ltix.hexdocs.pm/storage-adapters.md
https://ltix.hexdocs.pm/error-handling.md
https://ltix.hexdocs.pm/advantage-services.md
https://ltix.hexdocs.pm/deep-linking.md
https://ltix.hexdocs.pm/grade-service.md
https://ltix.hexdocs.pm/memberships-service.md
https://ltix.hexdocs.pm/working-with-roles.md
https://ltix.hexdocs.pm/jwk-management.md
https://ltix.hexdocs.pm/telemetry.md
# Cookbooks
https://ltix.hexdocs.pm/testing-lti-launches.md
https://ltix.hexdocs.pm/score-construction.md
https://ltix.hexdocs.pm/building-content-items.md
https://ltix.hexdocs.pm/token-caching-and-reuse.md
https://ltix.hexdocs.pm/auto-create-deployments.md
https://ltix.hexdocs.pm/background-grade-sync.md
https://ltix.hexdocs.pm/managing-jwks-with-ecto.md
https://ltix.hexdocs.pm/canvas-grade-extensions.md
```
Use the `.md` URLs with `curl` or web fetch tools for detailed reference when the mix
tasks don't give enough context.
## Skills
Ltix includes two pre-built skills for common integration tasks. These are step-by-step
guides with collaboration checkpoints. They prompt you to ask the user about app-specific
decisions before writing code.
- **`implement-storage-adapter`** - implement `Ltix.StorageAdapter` with Ecto schemas,
nonce management, and deployment policy decisions
- **`add-lti-launch`** - set up LTI 1.3 launch endpoints in a Phoenix app (routes,
session config, CSRF pipeline, controller skeleton)