lib/charon/models/session.ex

defmodule Charon.Models.Session do
  @moduledoc """
  A session.
  """
  @latest_version 7

  @enforce_keys [
    :created_at,
    :expires_at,
    :id,
    :refresh_expires_at,
    :refresh_token_id,
    :refreshed_at,
    :tokens_fresh_from,
    :user_id
  ]
  defstruct [
    :created_at,
    :expires_at,
    :id,
    :refresh_expires_at,
    :refresh_token_id,
    :refreshed_at,
    :tokens_fresh_from,
    :user_id,
    extra_payload: %{},
    lock_version: 0,
    prev_tokens_fresh_from: 0,
    type: :full,
    version: @latest_version
  ]

  @type t :: %__MODULE__{
          created_at: integer,
          expires_at: integer | :infinite,
          extra_payload: map(),
          id: String.t(),
          lock_version: integer(),
          prev_tokens_fresh_from: integer,
          refresh_expires_at: integer,
          refresh_token_id: binary(),
          refreshed_at: integer,
          tokens_fresh_from: integer,
          type: atom(),
          user_id: pos_integer | binary(),
          version: pos_integer
        }

  alias Charon.{Config, Internal}

  @doc """
  Upgrade a session (or map created from a session struct) to the latest struct version (#{@latest_version}).

  ## DocTests

      @charon_config Charon.Config.from_enum(token_issuer: "local")

      # old version without the :version, :refesh_expires_at fields but with :__struct__ set
      # is updated to latest version (#{@latest_version})
      iex> session = %{
      ...>   __struct__: Session,
      ...>   created_at: 0,
      ...>   expires_at: 1,
      ...>   extra_payload: %{},
      ...>   id: "ab",
      ...>   refresh_token_id: "cd",
      ...>   refreshed_at: 15,
      ...>   type: :full,
      ...>   user_id: 9
      ...> }
      ...> |> upgrade_version(@charon_config)
      iex> %Session{
      ...>   created_at: 0,
      ...>   expires_at: 1,
      ...>   extra_payload: %{},
      ...>   id: "ab",
      ...>   prev_tokens_fresh_from: 0,
      ...>   refresh_expires_at: 1,
      ...>   refresh_token_id: "cd",
      ...>   refreshed_at: 15,
      ...>   tokens_fresh_from: 15,
      ...>   type: :full,
      ...>   user_id: 9,
      ...>   version: #{@latest_version}
      ...> } = session

      # old version - with :expires_at = nil - is updated without error
      iex> session = %{
      ...>   __struct__: Session,
      ...>   created_at: 0,
      ...>   expires_at: nil,
      ...>   extra_payload: %{},
      ...>   id: "ab",
      ...>   refresh_token_id: "cd",
      ...>   refreshed_at: 15,
      ...>   type: :full,
      ...>   user_id: 9
      ...> }
      ...> |> upgrade_version(@charon_config)
      iex> %Session{
      ...>   created_at: 0,
      ...>   expires_at: :infinite,
      ...>   extra_payload: %{},
      ...>   id: "ab",
      ...>   prev_tokens_fresh_from: 0,
      ...>   refresh_expires_at: refresh_exp,
      ...>   refresh_token_id: "cd",
      ...>   refreshed_at: 15,
      ...>   tokens_fresh_from: 15,
      ...>   type: :full,
      ...>   user_id: 9,
      ...>   version: #{@latest_version}
      ...> } = session
      iex> is_integer(refresh_exp) and refresh_exp >= System.os_time(:second)
      true
  """
  @spec upgrade_version(map, Config.t()) :: map
  def upgrade_version(session, config) do
    session = session |> Map.delete(:__struct__) |> update(config)
    struct!(__MODULE__, session)
  end

  ###########
  # Private #
  ###########

  defp update(session = %{version: @latest_version}, _), do: session

  # v6: no lock_version yet
  defp update(session = %{version: 6}, config) do
    session |> Map.merge(%{version: 7, lock_version: 0}) |> update(config)
  end

  # v5: tokens_fresh_from was still called t_gen_fresh_at, which is less descriptive
  defp update(session = %{version: 5, prev_t_gen_fresh_at: p, t_gen_fresh_at: c}, config) do
    session
    |> Map.drop([:prev_t_gen_fresh_at, :t_gen_fresh_at])
    |> Map.merge(%{version: 6, prev_tokens_fresh_from: p, tokens_fresh_from: c})
    |> update(config)
  end

  # v4: session has no :t_gen_fresh_at or :prev_t_gen_fresh_at
  defp update(session = %{version: 4, refreshed_at: refreshed_at}, config) do
    session
    |> Map.merge(%{version: 5, prev_t_gen_fresh_at: 0, t_gen_fresh_at: refreshed_at})
    |> update(config)
  end

  # v3: session still has :refresh_tokens, :refresh_tokens_at and :prev_refresh_tokens
  defp update(session = %{version: 3, refresh_tokens: rt_ids}, config) do
    session
    |> Map.drop([:refresh_tokens, :refresh_tokens_at, :prev_refresh_tokens])
    |> Map.merge(%{version: 4, refresh_token_id: List.first(rt_ids, "unknown")})
    |> update(config)
  end

  # v2: we're back to v2 in v4, so we can jump to it immediately
  defp update(session = %{version: 2}, config), do: %{session | version: 4} |> update(config)

  # v1: session has no :refresh_expires_at
  defp update(session = %{version: 1}, config) do
    refresh_exp = min(session.expires_at, config.refresh_token_ttl + Internal.now())
    session |> Map.merge(%{version: 2, refresh_expires_at: refresh_exp}) |> update(config)
  end

  # v0: session has no :version and may have :expires_at = nil
  defp update(session, config) do
    session
    |> Map.merge(%{version: 1, expires_at: session[:expires_at] || :infinite})
    |> update(config)
  end
end