Ltix doesn't assume your database, ORM, or persistence strategy. Your application manages 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 mistakes.

Callback overview

CallbackWhen it's calledWhat it does
get_registration/3Login initiation and launch validationLook up a platform by issuer and optional client_id
get_deployment/3Launch validationLook up a deployment by registration and deployment_id
validate_nonce/4Launch validationRecord a nonce as used, and reject one already used
key_id/2Service authentication and deep linking responsesReturn the ID of the key that signs for a registration
sign/4Service authentication and deep linking responsesSign a message with that key

key_id/2 and sign/4 are optional. A tool that only handles launches doesn't need them.

The last argument of every callback is a keyword list of adapter options. It is [] when you pass none.

When storage fails, return {:error, reason} from any callback. The login or launch then returns {:error, %Ltix.Errors.Unknown.StorageError{}}, whose result is what the callback returned. A reason that is already an Ltix error is returned unchanged. Any other unexpected return value is also reported as a StorageError.

Registration lookups

@callback get_registration(
            issuer :: String.t(),
            client_id :: String.t() | nil,
            opts :: keyword()
          ) ::
            {:ok, Registerable.t()} | {:error, :not_found | term()}

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.

Ltix calls get_registration/3 during login initiation with the login request's issuer and client_id, and again during launch validation with the ID Token's issuer and audience. The client_id may be nil because some platforms don't include it in the login initiation request. Your adapter must handle both cases.

def get_registration(issuer, nil, _opts) do
  case Repo.all(from r in PlatformRegistration, where: r.issuer == ^issuer) do
    [record] -> {:ok, record}
    _none_or_several -> {:error, :not_found}
  end
end

def get_registration(issuer, client_id, _opts) 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. If a platform gives you an audience for token requests that differs from its token endpoint, store it and pass it as audience.

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
    })
  end
end

Ambiguous issuer-only lookups

If you support multiple registrations from the same issuer (different client_ids), an issuer-only lookup is ambiguous. A launch succeeds only for the registration the login found, so returning the first match fails launches for the others. Return {:error, :not_found} to require the platform to include client_id, as the example above does.

Deployment lookups

@callback get_deployment(
            registration :: Registerable.t(),
            deployment_id :: String.t(),
            opts :: keyword()
          ) ::
            {:ok, Deployable.t()} | {:error, :not_found | term()}

Return any struct that implements Ltix.Deployable. Like registrations, your original struct is preserved in the Ltix.LaunchContext.

The registration parameter is the struct your get_registration/3 returned. The deployment_id is case-sensitive and assigned by the platform.

def get_deployment(%PlatformRegistration{id: registration_id}, deployment_id, _opts) do
  case Repo.get_by(PlatformDeployment,
         registration_id: registration_id,
         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 replayed launches. After Ltix verifies an ID token, it calls validate_nonce/4 with the token's nonce and the time the token expires. Record the nonce as used and return :ok, or return {:error, :nonce_already_used} if it's already recorded. Logins don't write anything to storage.

Recording has to be atomic, so only one of two launches with the same nonce succeeds. Keep each record until expires_at, then delete it.

In-memory (development)

use Agent

def start_link(_opts) do
  Agent.start_link(fn -> MapSet.new() end, name: __MODULE__)
end

@impl true
def validate_nonce(nonce, _registration, _expires_at, _opts) do
  Agent.get_and_update(__MODULE__, fn used ->
    if MapSet.member?(used, nonce),
      do: {{:error, :nonce_already_used}, used},
      else: {:ok, MapSet.put(used, nonce)}
  end)
end

Ecto (production)

A nonce table keyed by the nonce.

# Migration
create table(:lti_nonces, primary_key: false) do
  add :nonce, :string, primary_key: true
  add :expires_at, :utc_datetime, null: false
end
@impl true
def validate_nonce(nonce, _registration, expires_at, _opts) do
  case Repo.insert_all(LtiNonce, [%{nonce: nonce, expires_at: expires_at}], on_conflict: :nothing) do
    {1, _} -> :ok
    {0, _} -> {:error, :nonce_already_used}
  end
end

The insert is atomic. If two requests race with the same nonce, only one of them inserts a row.

Nonce expiry

Add a periodic cleanup job that deletes nonces past their expires_at.

Repo.delete_all(from n in LtiNonce, where: n.expires_at < ^DateTime.utc_now())

Signing

Ltix signs two things for your tool, the token requests for Advantage services and deep linking responses. It builds each JWT and calls your adapter to get the signature, so your private key stays wherever you keep it.

Both callbacks receive the registration struct you gave Ltix, either from get_registration/3 during a launch or as an argument to a function like Ltix.OAuth.authenticate/2. key_id/2 returns the ID of the key that signs for it. Platforms use that ID to find the public key in your key set. sign/4 receives the same ID with the message to sign.

For keys stored in your database, Ltix.JWK.sign/2 produces the signature.

@impl true
def key_id(%PlatformRegistration{signing_key_id: signing_key_id}, _opts) do
  {:ok, Repo.get!(SigningKey, signing_key_id).kid}
end

@impl true
def sign(_registration, key_id, message, _opts) do
  case Repo.get_by(SigningKey, kid: key_id) do
    nil -> {:error, :unknown_key}
    key -> Ltix.JWK.sign(Ltix.JWK.new!(private_key_pem: key.private_key_pem, kid: key.kid), message)
  end
end

For a key stored in a key management service, call the service from sign/4 and return {:ok, signature} with its RS256 (RSASSA-PKCS1-v1_5 with SHA-256) signature of the message.

Calling a service or building a deep linking response with an adapter that lacks these callbacks returns Ltix.Errors.Unknown.StorageError.

Putting it together

The following is 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, _opts) do
    case Repo.all(from r in PlatformRegistration, where: r.issuer == ^issuer) do
      [record] -> {:ok, record}
      _none_or_several -> {:error, :not_found}
    end
  end

  def get_registration(issuer, client_id, _opts) 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(%PlatformRegistration{id: registration_id}, deployment_id, _opts) do
    case Repo.get_by(PlatformDeployment,
           registration_id: registration_id,
           deployment_id: deployment_id
         ) do
      nil -> {:error, :not_found}
      record -> {:ok, record}
    end
  end

  @impl true
  def validate_nonce(nonce, _registration, expires_at, _opts) do
    case Repo.insert_all(LtiNonce, [%{nonce: nonce, expires_at: expires_at}], on_conflict: :nothing) do
      {1, _} -> :ok
      {0, _} -> {:error, :nonce_already_used}
    end
  end
end

The protocol implementations are defined for 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
    })
  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{}, so you can access your own fields directly.

context.registration.id          #=> 42
context.registration.tenant_id   #=> 7
context.deployment.id            #=> 99

Testing your adapter

Use Ltix.Test.assert_storage_adapter!/2 to check registration and deployment lookups, returned records, nonce replay prevention, and signing. It returns :ok or raises ExUnit.AssertionError with the failing contract check.

Pass factories that persist the supplied registration and deployment details and return your application structs. Run against isolated test storage and clean up through your normal test setup.

test "storage adapter satisfies the contract" do
  parent = self()

  assert :ok = Ltix.Test.assert_storage_adapter!(MyApp.LtiStorage,
    registration: fn registration ->
      MyApp.Lti.create_registration!(Map.from_struct(registration))
    end,
    deployment: fn deployment, registration ->
      MyApp.Lti.create_deployment!(%{
        deployment_id: deployment.deployment_id,
        registration_id: registration.id
      })
    end,
    jwks: fn _registration -> MyApp.Lti.published_jwks() end,
    prepare_process: fn ->
      Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, parent, self())
    end
  )
end

When your adapter has key_id/2 and sign/4, pass :jwks a function that returns the key set your JWKS endpoint serves. The check signs a message through your adapter and verifies it against the key in that key set whose ID key_id/2 returned. Leave :jwks out for an adapter without the signing callbacks.

Preserve the lookup identifiers supplied to your factories. If lookups preload associations or change metadata, pass registration_identity: & &1.id and deployment_identity: & &1.id, or projections suited to your records.

Omit :prepare_process when your adapter needs neither database sandbox access nor process-local setup. For adapters that auto-create deployments, pass auto_create_deployments: true and return distinct storage identities to check deployment ownership. See Ltix.Test.assert_storage_adapter!/2 for all options.

Adapter options

Pass :storage_adapter_options to give your adapter values from the request, such as a tenant. Every callback receives the list as its last argument.

def login(conn, params) do
  {:ok, %{redirect_uri: url, state: state}} =
    Ltix.handle_login(params, url(conn, ~p"/lti/launch"),
      storage_adapter_options: [tenant: conn.assigns.tenant]
    )

  conn
  |> put_session(:lti_state, state)
  |> redirect(external: url)
end
@impl true
def get_registration(issuer, client_id, opts) do
  case Repo.get_by(PlatformRegistration,
         tenant_id: opts[:tenant].id,
         issuer: issuer,
         client_id: client_id
       ) do
    nil -> {:error, :not_found}
    record -> {:ok, record}
  end
end

Ltix stores nothing between login and launch, so pass the same options to Ltix.handle_callback/3. Ltix.OAuth.authenticate/2, the service authenticate/2 functions, Ltix.OAuth.Client.from_access_token/2, and Ltix.DeepLinking.build_response/3 take the option too, and pass it to key_id/2 and sign/4. An Ltix.OAuth.Client keeps the options it was created with and uses them when it refreshes its token.

Deployment ID in registration lookups

Platforms usually send the deployment ID with the login request. When they do, get_registration/3 receives it as opts[:deployment_id], during login and again during the launch. Ltix has not verified it at that point. Use it to choose where to look. get_deployment/3 checks that the deployment exists.

To check an adapter that needs options, pass the same :storage_adapter_options to Ltix.Test.assert_storage_adapter!/2.

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.