lib/charon/session_store/redis_store.ex

if Code.ensure_loaded?(Redix) and Code.ensure_loaded?(:poolboy) do
  defmodule Charon.SessionStore.RedisStore do
    @supervisor_opts_docs """
      - `:name` (default module name) name of the supervisor
      - `:pool_size` (default 10) total number of (local) normal workers
      - `:pool_max_overflow` (default 5) max number of (local) extra workers in case of high load
      - `:redix_opts` passed to `Redix.start_link/1`

    The pool and its config options are local to the Elixir/OTP node,
    so if you use multiple nodes, the total connection count to Redis maxes out at
    `(pool_size + pool_max_overflow) * number_of_nodes`.
    """

    @moduledoc """
    A persistent session store based on Redis, which implements behaviour `Charon.SessionStore`.
    In addition to the required callbacks, this store also provides `get_all/3` and `delete_all/3` (for a user) functions.

    ## Redis requirements

    This module needs a Redis >= 7.0.0 instance and needs permissions to create Redis functions.
    The optimistic-locking functionality of the store was not designed with a Redis cluster in mind
    and will behave unpredictably when used with a distributed Redis deployment.
    Using a failover-replica should be fine, however.
    The Redis functions are registered with a name that includes the hashed Charon version,
    to make sure that the called function matches the expected code,
    and multiple Charon deployments can share a Redis instance.

    ## Config

    Additional config is required for this module (see `Charon.SessionStore.RedisStore.Config`):

        Charon.Config.from_enum(
          ...,
          optional_modules: %{
            Charon.SessionStore.RedisStore => %{
              key_prefix: "charon_",
              get_signing_key: &RedisStore.default_signing_key/1
            }
          }
        )

    The following options are supported:
      - `:key_prefix` (optional). A string prefix for the Redis keys that are sessions.
      - `:get_signing_key` (optional). A getter/1 that returns the key that is used to sign and verify serialized session binaries.

    ## Initialize store

    RedisStore uses a connection pool and has to register Redis functions on startup.
    In order to initialize it, you must add RedisStore to your application's supervision tree.

        # in application.ex
        def start(_, ) do
          redix_opts = [host: "localhost", port: 6379, password: "supersecret", database: 0]

          children = [
            ...
            {Charon.SessionStore.RedisStore, pool_size: 15, redix_opts: redix_opts},
            ...
          ]

          opts = [strategy: :one_for_one, name: MyApp.Supervisor]
          Supervisor.start_link(children, opts)
        end

    ### Options
    #{@supervisor_opts_docs}

    ## Session signing

    In order to offer some defense-in-depth against a compromised Redis server,
    the serialized sessions are signed using a HMAC. The key is derived from the Charon
    base secret, but can be overridden in the config. This is not usually necessary, except
    when you're changing the base secret and still want to access your sessions.
    It is debatable, of course, in case your Redis server is compromised,
    if your application server holding the signing key can still be considered secure.
    However, given that there are no real drawbacks to using signed session binaries,
    since the performance cost is negligible, no extra config is needed, it is easy to implement,
    and defense-in-depth is a good guiding principle, RedisStore implements this feature anyway.

    ## Implementation details

    RedisStore stores sessions until their associated refresh token(s) expire.
    That means that `:refresh_expires_at` is used to determine if a session is "alive",
    not `:expires_at`.
    This makes sense, because a session without a valid refresh token is effectively useless,
    and it means we can support "infinite lifetime" sessions while still pruning sessions that aren't used.
    This is one reason to set `:refresh_ttl` to a limited value, no more than six months is recommended.

    Last but not least, [Redis 7 functions](https://redis.io/docs/manual/programmability/functions-intro/)
    are used to implement some features, specifically optimistic locking, and to ensure all callbacks
    use only a single round-trip to the Redis instance.
    """
    use Supervisor
    alias Charon.SessionStore.Behaviour, as: SessionStoreBehaviour
    @behaviour SessionStoreBehaviour
    alias Charon.{Config, Utils}
    alias __MODULE__.{LuaFunctions, ConnectionPool, StoreImpl}
    import Utils.{KeyGenerator}
    require Logger

    @doc """
    Start the RedisStore supervisor, which registeres all required Redis functions and initiates the connection pool.

    ## Options
    #{@supervisor_opts_docs}
    """
    @spec start_link(keyword) :: :ignore | {:error, any} | {:ok, pid}
    def start_link(opts \\ []) do
      name = opts[:name] || __MODULE__
      size = opts[:pool_size] || 10
      max_overflow = opts[:pool_max_overflow] || 5
      redix_opts = opts[:redix_opts] || []

      :ok = LuaFunctions.register_functions(redix_opts)

      init_arg = [
        pool_opts: [
          size: size,
          max_overflow: max_overflow,
          redix_opts: redix_opts,
          name: String.to_atom("#{name}.Pool")
        ]
      ]

      Supervisor.start_link(__MODULE__, init_arg, name: name)
    end

    @doc false
    @impl Supervisor
    def init(opts) do
      [{ConnectionPool, opts[:pool_opts]}] |> Supervisor.init(strategy: :one_for_one)
    end

    @impl SessionStoreBehaviour
    defdelegate get(session_id, user_id, type, config), to: StoreImpl

    @impl SessionStoreBehaviour
    defdelegate upsert(session, config), to: StoreImpl

    @impl SessionStoreBehaviour
    defdelegate delete(session_id, user_id, type, config), to: StoreImpl

    @impl SessionStoreBehaviour
    defdelegate get_all(user_id, type, config), to: StoreImpl

    @impl SessionStoreBehaviour
    defdelegate delete_all(user_id, type, config), to: StoreImpl

    @doc false
    def init_config(enum), do: __MODULE__.Config.from_enum(enum)

    @doc """
    Get the default session signing key that is used if config option `:get_signing_key` is not set explicitly.
    """
    @spec default_signing_key(Config.t()) :: binary
    def default_signing_key(config), do: derive_key(config.get_base_secret.(), "RedisStore HMAC")
  end
else
  defmodule Charon.SessionStore.RedisStore do
    @message "Optional dependencies `:redix` and `:poolboy` must be loaded to use this module."
    @moduledoc @message

    def get(_, _, _, _), do: raise(@message)
    def upsert(_, _), do: raise(@message)
    def delete(_, _, _, _), do: raise(@message)
    def get_all(_, _, _), do: raise(@message)
    def delete_all(_, _, _), do: raise(@message)

    def init_config(_), do: raise(@message)
    def default_signing_key(_), do: raise(@message)
  end
end