Managing JWKs with Ecto

Copy Markdown View Source

This recipe sets up database-backed JWK storage with a dedicated table, a JWKS controller, and a key rotation function. If you haven't read the JWK Management guide yet, start there for background on key pairs, JWKS endpoints, and rotation.

Migration

A dedicated table keeps key management separate from registration logic and makes rotation straightforward:

create table(:lti_jwks) do
  add :private_jwk, :binary, null: false
  add :active, :boolean, default: true
  timestamps()
end

alter table(:platform_registrations) do
  add :jwk_id, references(:lti_jwks), null: false
end

Encrypt private keys at rest

The private_jwk column holds secret key material. Use Cloak or your database's native encryption to encrypt it at rest.

Creating registrations with keys

Generate a key pair when creating a registration, and store only the private key:

defmodule MyApp.Lti do
  def create_registration(attrs) do
    {private, _public} = Ltix.JWK.generate_key_pair()

    jwk =
      Repo.insert!(%LtiJwk{
        private_jwk: serialize(private)
      })

    %PlatformRegistration{}
    |> PlatformRegistration.changeset(Map.put(attrs, :jwk_id, jwk.id))
    |> Repo.insert()
  end

  defp serialize(jwk) do
    jwk |> JOSE.JWK.to_map() |> elem(1) |> Jason.encode!()
  end

  defp deserialize(json) do
    json |> JSON.decode!() |> JOSE.JWK.from_map()
  end
end

Your storage adapter preloads the key and deserializes it:

defp to_registration(record) do
  %Ltix.Registration{
    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: deserialize(record.jwk.private_jwk)
  }
end

JWKS controller

Serve all active public keys. to_jwks/1 derives public halves from private keys automatically:

defmodule MyAppWeb.JwksController do
  use MyAppWeb, :controller

  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Lti.LtiJwk

  def index(conn, _params) do
    keys =
      from(j in LtiJwk, where: j.active == true)
      |> Repo.all()
      |> Enum.map(fn jwk ->
        jwk.private_jwk |> JSON.decode!() |> JOSE.JWK.from_map()
      end)

    conn
    |> put_resp_content_type("application/json")
    |> json(Ltix.JWK.to_jwks(keys))
  end
end
# router.ex
get "/.well-known/jwks.json", JwksController, :index

Cache the JWKS response

Platforms may fetch your JWKS endpoint frequently. Consider caching the response in ETS or with a cache header rather than querying the database on each request.

Key rotation

Create a new key row, point the registration at it, and leave the old key active so platforms can still verify during the overlap period:

def rotate_key(registration_id) do
  {new_private, _public} = Ltix.JWK.generate_key_pair()

  new_jwk =
    Repo.insert!(%LtiJwk{
      private_jwk: serialize(new_private)
    })

  Repo.get!(PlatformRegistration, registration_id)
  |> PlatformRegistration.changeset(%{jwk_id: new_jwk.id})
  |> Repo.update!()
end

The old key stays active: true, so the JWKS endpoint continues serving it. After platforms have refreshed their cache (24-48 hours is typical), mark it inactive:

Repo.update!(LtiJwk.changeset(old_jwk, %{active: false}))