Ltix doesn't assume your database, ORM, or persistence strategy. Your
application owns all storage and provides lookups through the
Ltix.StorageAdapter behaviour. This guide covers what each callback
does, how to build a production-ready implementation with Ecto, and
common pitfalls.
Callback overview
| Callback | When it's called | What it does |
|---|---|---|
get_registration/2 | Login initiation | Look up a platform by issuer and optional client_id |
get_deployment/2 | Launch validation | Look up a deployment by registration and deployment_id |
store_nonce/2 | Login initiation | Persist a nonce for later verification |
validate_nonce/2 | Launch validation | Verify a nonce was issued by us, then consume it |
Registration lookups
@callback get_registration(issuer :: String.t(), client_id :: String.t() | nil) ::
{:ok, Registerable.t()} | {:error, :not_found}Return any struct that implements Ltix.Registerable. The library
extracts the Ltix.Registration it needs via the protocol, and
your original struct is preserved in the Ltix.LaunchContext — so
you can access your own fields (database IDs, tenant info, etc.)
after a successful launch without an extra query.
The client_id may be nil — some platforms don't include it in the
login initiation request. Your adapter must handle both cases:
def get_registration(issuer, nil) do
case Repo.get_by(PlatformRegistration, issuer: issuer) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end
def get_registration(issuer, client_id) do
case Repo.get_by(PlatformRegistration, issuer: issuer, client_id: client_id) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
endYour Ecto schema implements Ltix.Registerable to map its fields:
defimpl Ltix.Registerable, for: MyApp.Lti.PlatformRegistration do
def to_registration(record) do
Ltix.Registration.new(%{
issuer: record.issuer,
client_id: record.client_id,
auth_endpoint: record.auth_endpoint,
jwks_uri: record.jwks_uri,
token_endpoint: record.token_endpoint,
tool_jwk: record.tool_jwk
})
end
endAmbiguous issuer-only lookups
If you support multiple registrations from the same issuer (different
client_ids), an issuer-only lookup is ambiguous. You can either return
the first match or return {:error, :not_found} and require the
platform to include client_id.
Deployment lookups
@callback get_deployment(registration :: Registration.t(), deployment_id :: String.t()) ::
{:ok, Deployable.t()} | {:error, :not_found}Return any struct that implements Ltix.Deployable. Like registrations,
your original struct is preserved in the Ltix.LaunchContext.
The registration parameter is the resolved Ltix.Registration (not
your custom struct). The deployment_id is case-sensitive and assigned
by the platform:
def get_deployment(%Ltix.Registration{} = reg, deployment_id) do
case Repo.get_by(PlatformDeployment,
registration_id: reg_id(reg),
deployment_id: deployment_id
) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
enddefimpl Ltix.Deployable, for: MyApp.Lti.PlatformDeployment do
def to_deployment(record) do
Ltix.Deployment.new(record.deployment_id)
end
endNonce management
Nonces prevent replay attacks. The flow is:
- During login, Ltix generates a random nonce and calls
store_nonce/2 - During launch validation, Ltix extracts the nonce from the JWT and
calls
validate_nonce/2 - Your adapter must check the nonce exists and consume it atomically
In-memory (development)
use Agent
def start_link(_opts) do
Agent.start_link(fn -> MapSet.new() end, name: __MODULE__)
end
@impl true
def store_nonce(nonce, _registration) do
Agent.update(__MODULE__, &MapSet.put(&1, nonce))
:ok
end
@impl true
def validate_nonce(nonce, _registration) do
Agent.get_and_update(__MODULE__, fn nonces ->
if MapSet.member?(nonces, nonce) do
{:ok, MapSet.delete(nonces, nonce)}
else
{{:error, :nonce_not_found}, nonces}
end
end)
endEcto (production)
A nonce table with atomic consume-on-validate:
# Migration
create table(:lti_nonces) do
add :nonce, :string, null: false
add :issuer, :string, null: false
timestamps(updated_at: false)
end
create unique_index(:lti_nonces, [:nonce, :issuer])@impl true
def store_nonce(nonce, %Ltix.Registration{issuer: issuer}) do
%LtiNonce{}
|> LtiNonce.changeset(%{nonce: nonce, issuer: issuer})
|> Repo.insert!()
:ok
end
@impl true
def validate_nonce(nonce, %Ltix.Registration{issuer: issuer}) do
case Repo.delete_all(
from n in LtiNonce,
where: n.nonce == ^nonce and n.issuer == ^issuer
) do
{1, _} -> :ok
{0, _} -> {:error, :nonce_not_found}
end
endThe DELETE ... WHERE is atomic — if two requests race with the same
nonce, only one will delete a row and succeed.
Nonce expiry
Nonces accumulate if launches fail before reaching the callback.
Add a periodic cleanup job that deletes nonces older than a few
minutes. The inserted_at timestamp makes this straightforward:
from(n in LtiNonce, where: n.inserted_at < ago(5, "minute"))
|> Repo.delete_all()Putting it together
A complete Ecto-backed adapter:
defmodule MyApp.LtiStorage do
@behaviour Ltix.StorageAdapter
import Ecto.Query
alias MyApp.Repo
alias MyApp.Lti.{LtiNonce, PlatformDeployment, PlatformRegistration}
@impl true
def get_registration(issuer, nil) do
case Repo.get_by(PlatformRegistration, issuer: issuer) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end
def get_registration(issuer, client_id) do
case Repo.get_by(PlatformRegistration, issuer: issuer, client_id: client_id) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end
@impl true
def get_deployment(%Ltix.Registration{issuer: issuer, client_id: client_id}, deployment_id) do
query =
from d in PlatformDeployment,
join: r in PlatformRegistration,
on: d.registration_id == r.id,
where: r.issuer == ^issuer and r.client_id == ^client_id,
where: d.deployment_id == ^deployment_id
case Repo.one(query) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end
@impl true
def store_nonce(nonce, %Ltix.Registration{issuer: issuer}) do
Repo.insert!(%LtiNonce{nonce: nonce, issuer: issuer})
:ok
end
@impl true
def validate_nonce(nonce, %Ltix.Registration{issuer: issuer}) do
case Repo.delete_all(
from n in LtiNonce,
where: n.nonce == ^nonce and n.issuer == ^issuer
) do
{1, _} -> :ok
{0, _} -> {:error, :nonce_not_found}
end
end
endThe protocol implementations live on your Ecto schemas:
defimpl Ltix.Registerable, for: MyApp.Lti.PlatformRegistration do
def to_registration(record) do
Ltix.Registration.new(%{
issuer: record.issuer,
client_id: record.client_id,
auth_endpoint: record.auth_endpoint,
jwks_uri: record.jwks_uri,
token_endpoint: record.token_endpoint,
tool_jwk: record.tool_jwk
})
end
end
defimpl Ltix.Deployable, for: MyApp.Lti.PlatformDeployment do
def to_deployment(record) do
Ltix.Deployment.new(record.deployment_id)
end
endAfter a successful launch, context.registration is your
%PlatformRegistration{} and context.deployment is your
%PlatformDeployment{} — access your own fields directly:
context.registration.id #=> 42
context.registration.tenant_id #=> 7
context.deployment.id #=> 99Per-call override
You can bypass the configured adapter for a specific call by passing
:storage_adapter in opts:
Ltix.handle_login(params, launch_url, storage_adapter: MyApp.TestStorage)This is useful in tests or when different routes use different storage backends.