Token Caching and Reuse

Copy Markdown View Source

Most LTI tools authenticate once per request or job and need no token management. But when a background job processes hundreds of grades or syncs multiple courses, understanding the token lifecycle avoids redundant authentication and handles mid-job expiry.

Refreshing during a batch

When processing many items in a single job, the token may expire before you finish. Call refresh!/1 between batches. The endpoint is rebuilt from the URL and scopes saved during a launch, as in Syncing Grades in the Background.

alias Ltix.LaunchClaims.AGSEndpoint

def perform(%{args: %{"course_id" => course_id}}) do
  registration = MyApp.Courses.get_registration(course_id)
  stored = MyApp.Courses.get_ags_endpoint(course_id)
  endpoint = AGSEndpoint.new!(line_item: stored.line_item_url, scopes: stored.scopes)

  {:ok, client} = Ltix.GradeService.authenticate(registration,
    endpoint: endpoint
  )

  course_id
  |> MyApp.Grades.pending_scores()
  |> Enum.chunk_every(50)
  |> Enum.reduce(client, fn batch, client ->
    client = ensure_fresh(client)

    Enum.each(batch, fn {user_id, score_given} ->
      {:ok, score} = Ltix.GradeService.Score.new(
        user_id: user_id,
        score_given: score_given,
        score_maximum: 100,
        activity_progress: :completed,
        grading_progress: :fully_graded
      )

      :ok = Ltix.GradeService.post_score(client, score)
    end)

    client
  end)

  :ok
end

defp ensure_fresh(client) do
  Ltix.OAuth.Client.refresh!(client)
end

refresh/1 requests a new token only when Ltix.OAuth.Client.expired?/1 is true for the current one. Otherwise it returns the client unchanged. The returned client has the same endpoints and registration, and only the token changes. To request a new token regardless of expiry, use force_refresh!/1.

Syncing multiple courses

A token is scoped to a registration (platform + client_id). If you sync several courses on the same platform in one job, authenticate once and swap endpoints with with_endpoints!/3.

def perform(%{args: %{"platform_id" => platform_id}}) do
  registration = MyApp.Platforms.get_registration(platform_id)
  courses = MyApp.Courses.for_platform(platform_id)

  # Bootstrap with any course's endpoint to acquire the token
  first_endpoint = MyApp.Courses.get_memberships_endpoint(hd(courses).id)

  {:ok, client} = Ltix.MembershipsService.authenticate(registration,
    endpoint: first_endpoint
  )

  Enum.reduce(courses, client, fn course, client ->
    endpoint = MyApp.Courses.get_memberships_endpoint(course.id)

    client = Ltix.OAuth.Client.with_endpoints!(client, %{
      Ltix.MembershipsService => endpoint
    })

    client = ensure_fresh(client)

    {:ok, roster} = Ltix.MembershipsService.list_members(client)
    MyApp.Courses.sync_roster(course.id, roster)

    client
  end)

  :ok
end

The token's granted scopes do not change. If it lacks a scope a new endpoint needs, that service returns a ScopeMismatch error when called. Pass require_all_scopes: true to with_endpoints/3 to get the error at swap time.

Caching tokens across workers

When many Oban workers call the same platform concurrently (say, one job per course), each authenticates independently by default. To share tokens, cache them in ETS. A token has only the scopes it was requested for, and a platform may limit it to the deployment it was requested for, so key the cache by the registration, the deployment, and the scopes the service requests. Workers calling the same service share one token, and a service that needs other scopes gets a separate token.

defmodule MyApp.LTI.TokenCache do
  @table :lti_token_cache

  alias Ltix.OAuth.{AccessToken, Client}

  def create_table do
    :ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
    :ok
  end

  def get_or_authenticate(registration, deployment, service, endpoint) do
    key =
      {registration.issuer, registration.client_id, deployment.deployment_id,
       Enum.sort(service.scopes(endpoint))}

    case :ets.lookup(@table, key) do
      [{^key, token}] ->
        if AccessToken.expired?(token) do
          authenticate_and_cache(key, registration, deployment, service, endpoint)
        else
          Client.from_access_token(token,
            registration: registration,
            deployment: deployment,
            endpoints: %{service => endpoint}
          )
        end

      [] ->
        authenticate_and_cache(key, registration, deployment, service, endpoint)
    end
  end

  defp authenticate_and_cache(key, registration, deployment, service, endpoint) do
    {:ok, client} =
      service.authenticate(registration, endpoint: endpoint, deployment: deployment)

    :ets.insert(@table, {key, client.token})
    {:ok, client}
  end
end

An ETS table is deleted when the process that created it exits, so create the table once when your application starts.

# lib/my_app/application.ex
def start(_type, _args) do
  :ok = MyApp.LTI.TokenCache.create_table()

  children = [
    MyApp.Repo,
    {Oban, Application.fetch_env!(:my_app, Oban)}
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

Use the cache in an Oban worker.

def perform(%{args: %{"course_id" => course_id}}) do
  registration = MyApp.Courses.get_registration(course_id)
  deployment = MyApp.Courses.get_deployment(course_id)
  endpoint = MyApp.Courses.get_memberships_endpoint(course_id)

  {:ok, client} = MyApp.LTI.TokenCache.get_or_authenticate(
    registration,
    deployment,
    Ltix.MembershipsService,
    endpoint
  )

  {:ok, roster} = Ltix.MembershipsService.list_members(client)
  MyApp.Courses.sync_roster(course_id, roster)
end

Race conditions

This ETS cache uses last-write-wins. Concurrent workers may both miss the cache and authenticate simultaneously, producing two valid tokens. This is harmless because the platform issues independent tokens, and one simply goes unused.

Storing tokens outside ETS

To share tokens across nodes, store them in your database or a shared cache. Ltix.OAuth.AccessToken.to_map/1 returns a map that encodes to JSON, and Ltix.OAuth.AccessToken.from_map/1 restores the token.

stored = Ltix.OAuth.AccessToken.to_map(client.token)

{:ok, token} = Ltix.OAuth.AccessToken.from_map(stored)

The map holds the bearer token, so store it like any other credential.

When to cache

Don't add caching complexity unless you have a reason.

ScenarioApproach
Single API call per jobJust authenticate. No caching needed.
One job, many API callsRefresh mid-batch with refresh!/1.
One job, many coursesAuthenticate once, swap with with_endpoints/3.
Many concurrent jobs, same platformCache tokens in ETS.

Next steps