When a launch fails, you need to know what went wrong and how to
respond. Ltix returns {:error, exception} from both handle_login/3
and handle_callback/3, with errors organized into three classes using
Splode. You can match on the category
without knowing every individual error type.
Error classes
| Class | Module | Meaning |
|---|---|---|
:invalid | Ltix.Errors.Invalid | Bad input (malformed JWT, missing claims, unknown registration) |
:security | Ltix.Errors.Security | Security violation (bad signature, expired token, nonce replay) |
:unknown | Ltix.Errors.Unknown | Unexpected failure (network errors, bugs) |
HTTP status codes
Every error has an HTTP status code derived from its class.
| Class | Status |
|---|---|
:invalid | 400 |
:security | 401 |
:unknown | 500 |
Use Ltix.Errors.status_code/1 to get it.
case Ltix.handle_callback(params, state) do
{:ok, context} ->
handle_launch(conn, context)
{:error, error} ->
conn
|> put_status(Ltix.Errors.status_code(error))
|> text(Exception.message(error))
endPlug.Exception
If your application depends on Plug, Ltix errors automatically implement
the Plug.Exception protocol. Phoenix error views and Plug.Debugger
use the correct status code without any extra work on your part.
Matching on class
Every Ltix error struct has a class field you can match on for
different handling per category.
case Ltix.handle_callback(params, state) do
{:ok, context} ->
handle_launch(conn, context)
{:error, error} ->
status = Ltix.Errors.status_code(error)
case error.class do
:invalid ->
conn |> put_status(status) |> text("Bad request: #{Exception.message(error)}")
:security ->
conn |> put_status(status) |> text("Unauthorized: #{Exception.message(error)}")
:unknown ->
Logger.error("LTI launch failed: #{Exception.message(error)}")
conn |> put_status(status) |> text("Internal error")
end
endMatching on specific errors
For finer control, match on the error struct directly.
case Ltix.handle_login(params, launch_url) do
{:ok, result} ->
# ...
{:error, %Ltix.Errors.Invalid.RegistrationNotFound{issuer: issuer}} ->
Logger.warning("Unknown platform attempted login: #{issuer}")
conn |> put_status(404) |> text("Platform not registered")
{:error, %Ltix.Errors.Invalid.MissingParameter{parameter: param}} ->
conn |> put_status(400) |> text("Missing required parameter: #{param}")
{:error, error} ->
conn |> put_status(400) |> text(Exception.message(error))
endSingle and grouped errors
A single failure returns its error struct directly. When validation finds several problems at once, such as two malformed claims, they are grouped in their class struct and errors lists each one.
case Ltix.LaunchClaims.from_json(claims) do
{:ok, claims} ->
claims
{:error, %Ltix.Errors.Invalid{errors: errors}} ->
Enum.each(errors, &Logger.warning(Exception.message(&1)))
{:error, error} ->
Logger.warning(Exception.message(error))
endThe message of a grouped error lists the message of each error it contains.
Invalid options
Ltix raises Ltix.Errors.Invalid.InvalidOption when a function gets an unknown option, a missing required option, or an option with a value of the wrong type.
Ltix.JWK.generate(key_sise: 4096)
** (Ltix.Errors.Invalid.InvalidOption) Option :key_sise is unknownError messages and spec references
Every error produces a human-readable message via Exception.message/1
and most have a spec_ref field with the violated spec section.
error = %Ltix.Errors.Security.TokenExpired{spec_ref: "Sec §5.1.3 step 7"}
Exception.message(error)
#=> "JWT token has expired [Sec §5.1.3 step 7]"
error.spec_ref
#=> "Sec §5.1.3 step 7"The spec reference is useful for debugging because it identifies exactly which validation step failed.
Invalid errors
These indicate problems with the incoming data or the calling code.
| Error | Fields | When |
|---|---|---|
MissingParameter | parameter, spec_ref | OIDC login request missing a required param |
MissingClaim | claim, spec_ref | A JWT or platform response, such as a memberships page, is missing a required claim, such as sub or resource_link.id |
InvalidClaim | claim, value, message, spec_ref | A claim in a JWT or platform response has an invalid value, or a custom claim parser returned an error |
InvalidField | struct, field, value, reason, spec_ref | A constructor such as Ltix.Registration.new/1, Ltix.GradeService.Score.new/1, or Ltix.DeepLinking.ContentItem.Link.new/1 got an unknown, missing, or invalid field, or a line item to create or update has an invalid field |
InvalidOption | key, value, reason | A function got an unknown, missing, or invalid option |
InvalidJSON | spec_ref | The ID token is not a JWT whose header and payload are JSON objects |
RegistrationNotFound | issuer, client_id | No registration matches the issuer |
DeploymentNotFound | deployment_id | No deployment matches the JWT's deployment_id |
Security errors
These indicate the launch failed a security check.
| Error | Fields | When |
|---|---|---|
SignatureInvalid | spec_ref | JWT signature doesn't verify against the platform's public key |
TokenExpired | spec_ref | JWT exp claim is in the past, or its iat claim is older than :max_token_age |
TokenNotYetValid | claim, spec_ref | JWT iat or nbf claim is in the future |
IssuerMismatch | expected, actual, spec_ref | JWT iss doesn't match the registration |
AudienceMismatch | claim, expected, actual, spec_ref | JWT aud is not the registration's client_id or a list containing only it, or azp is not the client_id |
AlgorithmNotAllowed | algorithm, spec_ref | JWT uses something other than RS256 |
StateMismatch | spec_ref | OIDC state from callback doesn't match the session |
NonceMissing | spec_ref | JWT has no nonce claim |
NonceNotFound | spec_ref | Nonce wasn't issued by this tool |
NonceReused | spec_ref | Nonce was already consumed (replay attempt) |
KidMissing | spec_ref | JWT header has no kid field |
KidNotFound | kid, spec_ref | kid not found in the platform's JWKS |
LocalNetworkAccessDenied | url, address_space | The platform's key set is on a local or loopback address that :local_network_access doesn't allow |
AuthenticationFailed | error, error_description, error_uri, spec_ref | Platform returned an error response |
Unknown errors
These indicate an unexpected failure outside the incoming request.
| Error | Fields | When |
|---|---|---|
StorageError | callback, result, reason | A storage adapter callback returned {:error, reason}, an unexpected value, or a deployment for a different deployment_id |
TransportError | status, body, headers, url, spec_ref | An HTTP request to the platform failed |
RedirectNotAllowed | status, url, location, spec_ref | A platform endpoint returned an unsafe redirect |
KeySetNotStarted | key_set | Raised when a launch needs a key set that is not started in the supervision tree |
Unknown | error | Any other unexpected failure |
Deep linking errors
These errors come from Ltix.DeepLinking.build_response/3. Content item constructors such as Ltix.DeepLinking.ContentItem.Link.new/1 return InvalidField. Errors about one item in a response include its index in the list you passed.
| Error | Fields | When |
|---|---|---|
InvalidContentItem | index, value, reason, spec_ref | A response item is not a content item or has no valid type, or the items are not a list |
InvalidMessageType | message_type, spec_ref | The launch is not a deep linking request |
ContentItemTypeNotAccepted | index, type, accept_types, spec_ref | An item's type is not in accept_types |
ContentItemsExceedLimit | count, spec_ref | Several items when accept_multiple is false |
LineItemNotAccepted | index, spec_ref | A resource link has a line item when accept_line_item is false |
PresentationTargetNotAccepted | index, target, accept_presentation_document_targets, spec_ref | An item's iframe, window, or embed is not in accept_presentation_document_targets |
MediaTypeNotAccepted | index, media_type, accept_media_types, spec_ref | A file's media type does not match accept_media_types |
Advantage service errors
These errors apply to LTI Advantage service calls (memberships, AGS, etc.) and OAuth authentication.
Invalid
| Error | Fields | When |
|---|---|---|
ServiceNotAvailable | service, reason, spec_ref | The launch has no claim for the service, an endpoint or URL the operation needs is missing, the endpoint does not support the service version, or the registration has no token endpoint |
TokenRequestFailed | error, error_description, status, body, spec_ref | OAuth token request failed |
MalformedResponse | service, reason, spec_ref | Platform returned an unparseable response |
CollectionTooLarge | service, option, count, max, spec_ref | An eager fetch such as list_results/2 exceeded its limit option, such as max_results |
ScopeMismatch | scopes, granted_scopes, spec_ref | Client lacks every scope in scopes that would authorize the request |
InvalidEndpoint | service, spec_ref | Wrong endpoint struct passed to a service |
MissingLineItemId | line_item, spec_ref | A grade operation received a line item without an id |
Security
| Error | Fields | When |
|---|---|---|
AccessDenied | status, body, url, spec_ref | Platform responded to a service request or key set fetch with HTTP 401 or 403 |
LocalNetworkAccessDenied | url, address_space | A platform request would connect to a local or loopback address that :local_network_access doesn't allow |
AccessTokenExpired | expires_at, spec_ref | OAuth access token has expired |
Unknown
| Error | Fields | When |
|---|---|---|
TransportError | status, body, headers, url, spec_ref | Ltix could not connect to the platform, or the endpoint URL is neither an absolute HTTPS URL nor an HTTP URL for a loopback or local host |
RedirectNotAllowed | status, url, location, spec_ref | The platform redirected to a different origin or an invalid location |
KeySetNotStarted | key_set | Raised when a launch needs a key set that is not started in the supervision tree |
Logging recommendations
Invalid errors are usually the platform's fault (misconfiguration, bad requests). Security errors could be attacks or clock skew. Unknown errors need investigation.
{:error, error} ->
case error.class do
:invalid ->
Logger.info("LTI invalid request: #{Exception.message(error)}")
:security ->
Logger.warning("LTI security violation: #{Exception.message(error)}")
:unknown ->
Logger.error("LTI unexpected error: #{Exception.message(error)}")
end