Token Caching and Reuse

Copy Markdown View Source

Most LTI tools authenticate once per request or job and never think about 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. Check between batches with expired?/1 and refresh!/1:

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

  {: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
  if Ltix.OAuth.Client.expired?(client) do
    Ltix.OAuth.Client.refresh!(client)
  else
    client
  end
end

refresh/1 re-derives scopes from the client's endpoints and requests a new token from the platform. The returned client keeps the same endpoints and registration — only the token changes.

Syncing multiple courses

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

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.get_members(client)
    MyApp.Courses.sync_roster(course.id, roster)

    client
  end)

  :ok
end

with_endpoints/2 validates that the client's granted scopes cover the new endpoint's requirements. If the platform granted fewer scopes than needed, you get a ScopeMismatch error at swap time rather than mid-request.

Caching tokens across workers

When many Oban workers hit the same platform concurrently — say, one job per course — each authenticates independently by default. To share a single token, cache it in ETS:

defmodule MyApp.LTI.TokenCache do
  @table :lti_token_cache

  alias Ltix.OAuth.{AccessToken, Client}

  def get_or_authenticate(registration, service, endpoint) do
    key = {registration.issuer, registration.client_id}
    ensure_table()

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

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

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

    token = %AccessToken{
      access_token: client.access_token,
      token_type: "bearer",
      granted_scopes: MapSet.to_list(client.scopes),
      expires_at: client.expires_at
    }

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

  defp token_expired?(%{expires_at: expires_at}) do
    DateTime.compare(DateTime.utc_now(), DateTime.add(expires_at, -60)) != :lt
  end

  defp ensure_table do
    :ets.new(@table, [:set, :public, :named_table])
  rescue
    ArgumentError -> :ok
  end
end

Usage in an Oban worker:

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

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

  {:ok, roster} = Ltix.MembershipsService.get_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 — the platform issues independent tokens, and one simply goes unused.

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 expired?/1 + refresh!/1.
One job, many coursesAuthenticate once, swap with with_endpoints/2.
Many concurrent jobs, same platformCache tokens in ETS.

Next steps