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

CallbackWhen it's calledWhat it does
get_registration/2Login initiationLook up a platform by issuer and optional client_id
get_deployment/2Launch validationLook up a deployment by registration and deployment_id
store_nonce/2Login initiationPersist a nonce for later verification
validate_nonce/2Launch validationVerify 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
end

Your 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
end

Ambiguous 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
end
defimpl Ltix.Deployable, for: MyApp.Lti.PlatformDeployment do
  def to_deployment(record) do
    Ltix.Deployment.new(record.deployment_id)
  end
end

Nonce management

Nonces prevent replay attacks. The flow is:

  1. During login, Ltix generates a random nonce and calls store_nonce/2
  2. During launch validation, Ltix extracts the nonce from the JWT and calls validate_nonce/2
  3. 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)
end

Ecto (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
end

The 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
end

The 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
end

After 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            #=> 99

Per-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.